Spaces:
Paused
Paused
Commit Β·
62be87c
1
Parent(s): fef4051
Refactor: Organize codebase with modular structure
Browse files- Move templates to pages/ folder for better organization
- Move core application logic to core/ folder
- Move AI tokenizers to ai/tokenizers/ for future AI service expansion
- Move database files to database/ folder
- Move configuration files to config/ folder
- Add proper HF Space configuration and branding
- Update README with NyanProxy branding and features
- Create root app.py for HF Space compatibility
- Add .env.example for configuration reference
- Add start.bat for Windows development convenience
- Update all import paths to work with new structure
- Maintain backward compatibility for existing deployments
π€ Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. Β See raw diff
- .claude/settings.local.json +27 -1
- .env +49 -11
- .env.example +25 -0
- .gitignore +7 -1
- AUTHENTICATION.md +334 -0
- README.md +5 -5
- {tokenizers β ai/tokenizers}/__init__.py +0 -0
- {tokenizers β ai/tokenizers}/anthropic_tokenizer.py +0 -0
- {tokenizers β ai/tokenizers}/claude.ts +0 -0
- {tokenizers β ai/tokenizers}/index.ts +0 -0
- {tokenizers β ai/tokenizers}/mistral-tokenizer-js.ts +0 -0
- {tokenizers β ai/tokenizers}/mistral.ts +0 -0
- {tokenizers β ai/tokenizers}/openai.ts +0 -0
- {tokenizers β ai/tokenizers}/openai_tokenizer.py +0 -0
- {tokenizers β ai/tokenizers}/tokenizer.ts +0 -0
- {tokenizers β ai/tokenizers}/unified_tokenizer.py +0 -0
- app.py +24 -895
- config/.env.example +51 -0
- config/requirements.txt +7 -0
- core/app.py +1057 -0
- health_checker.py β core/health_checker.py +1 -1
- pages/admin/anti_abuse.html +107 -0
- pages/admin/base.html +419 -0
- pages/admin/bulk_operations.html +128 -0
- pages/admin/create_user.html +372 -0
- pages/admin/dashboard.html +502 -0
- pages/admin/edit_user.html +202 -0
- pages/admin/key_manager.html +357 -0
- pages/admin/list_users.html +247 -0
- pages/admin/login.html +84 -0
- pages/admin/model_families.html +402 -0
- pages/admin/stats.html +60 -0
- pages/admin/view_user.html +265 -0
- pages/admin_dashboard.html +587 -0
- {templates β pages}/dashboard.html +11 -4
- requirements.txt +3 -1
- run.py +30 -0
- src/__init__.py +1 -0
- src/config/__init__.py +1 -0
- src/config/auth.py +76 -0
- src/middleware/__init__.py +1 -0
- src/middleware/auth.py +257 -0
- src/routes/__init__.py +1 -0
- src/routes/admin.py +379 -0
- src/routes/admin_web.py +507 -0
- src/routes/model_families_admin.py +249 -0
- src/services/__init__.py +1 -0
- src/services/event_logger.py +194 -0
- src/services/model_families.py +501 -0
- src/services/structured_event_logger.py +628 -0
.claude/settings.local.json
CHANGED
|
@@ -20,7 +20,33 @@
|
|
| 20 |
"Bash(git checkout:*)",
|
| 21 |
"Bash(git merge:*)",
|
| 22 |
"WebFetch(domain:gitgud.io)",
|
| 23 |
-
"Bash(git commit:*)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
],
|
| 25 |
"deny": []
|
| 26 |
}
|
|
|
|
| 20 |
"Bash(git checkout:*)",
|
| 21 |
"Bash(git merge:*)",
|
| 22 |
"WebFetch(domain:gitgud.io)",
|
| 23 |
+
"Bash(git commit:*)",
|
| 24 |
+
"Bash(git rm:*)",
|
| 25 |
+
"Bash(git remote set-url:*)",
|
| 26 |
+
"Bash(mkdir:*)",
|
| 27 |
+
"WebFetch(domain:code.jquery.com)",
|
| 28 |
+
"Bash(curl:*)",
|
| 29 |
+
"Bash(mv:*)",
|
| 30 |
+
"Bash(\"C:\\Users\\rafae\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" app.py --port 8080)",
|
| 31 |
+
"Bash(\"C:\\Users\\rafae\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" -c \"from src.services.model_families import model_manager; print(''Model manager initialized successfully'')\")",
|
| 32 |
+
"Bash(timeout:*)",
|
| 33 |
+
"WebFetch(domain:127.0.0.1)",
|
| 34 |
+
"Bash(\"C:\\Users\\rafae\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" debug_keys.py)",
|
| 35 |
+
"Bash(\"C:\\Users\\rafae\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" test_all_keys.py)",
|
| 36 |
+
"Bash(del debug_keys.py test_all_keys.py)",
|
| 37 |
+
"Bash(\"C:\\Users\\rafae\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" app.py)",
|
| 38 |
+
"Bash(tasklist:*)",
|
| 39 |
+
"Bash(powershell:*)",
|
| 40 |
+
"Bash(cmd /c:*)",
|
| 41 |
+
"Bash(true)",
|
| 42 |
+
"Bash(\"C:\\Users\\rafae\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" run.py)",
|
| 43 |
+
"Bash(where py)",
|
| 44 |
+
"Bash(set PATH)",
|
| 45 |
+
"Bash(\"C:\\Windows\\py.exe\" app.py)",
|
| 46 |
+
"Bash(dir:*)",
|
| 47 |
+
"Bash(ls:*)",
|
| 48 |
+
"WebFetch(domain:huggingface.co)",
|
| 49 |
+
"Bash(cp:*)"
|
| 50 |
],
|
| 51 |
"deny": []
|
| 52 |
}
|
.env
CHANGED
|
@@ -1,16 +1,54 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
BRAND_NAME=NyanProxy
|
| 8 |
BRAND_EMOJI=π±
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
# ANTHROPIC_API_KEYS=sk-ant-your-key1,sk-ant-your-key2
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
|
|
|
| 1 |
+
# === AUTHENTICATION CONFIGURATION ===
|
| 2 |
+
# Choose authentication mode: "none", "proxy_key", or "user_token"
|
| 3 |
+
AUTH_MODE=user_token
|
| 4 |
+
|
| 5 |
+
# For proxy_key mode: single shared password
|
| 6 |
+
PROXY_PASSWORD=proxy_pass
|
| 7 |
+
|
| 8 |
+
# Admin key for accessing admin endpoints
|
| 9 |
+
ADMIN_KEY=matcha
|
| 10 |
+
|
| 11 |
+
# Flask secret key for sessions (change this to a random value in production)
|
| 12 |
+
FLASK_SECRET_KEY=nyan-cat-secret-key-change-in-production-2024
|
| 13 |
+
|
| 14 |
+
# === USER TOKEN CONFIGURATION ===
|
| 15 |
+
# Maximum IP addresses per user (default: 3)
|
| 16 |
+
MAX_IPS_PER_USER=6
|
| 17 |
+
|
| 18 |
+
# Auto-ban users who exceed IP limit (default: true)
|
| 19 |
+
MAX_IPS_AUTO_BAN=true
|
| 20 |
+
|
| 21 |
+
# === RATE LIMITING ===
|
| 22 |
+
# Enable rate limiting (default: true)
|
| 23 |
+
RATE_LIMIT_ENABLED=true
|
| 24 |
|
| 25 |
+
# Requests per minute per user/IP (default: 60)
|
| 26 |
+
RATE_LIMIT_PER_MINUTE=60
|
| 27 |
+
|
| 28 |
+
# === FIREBASE CONFIGURATION ===
|
| 29 |
+
# Firebase Realtime Database URL
|
| 30 |
+
FIREBASE_URL=https://arfy-user-management-default-rtdb.firebaseio.com
|
| 31 |
+
|
| 32 |
+
# Firebase service account key (JSON string)
|
| 33 |
+
# You can also use Application Default Credentials instead
|
| 34 |
+
FIREBASE_SERVICE_ACCOUNT_KEY=ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAiYXJmeS11c2VyLW1hbmFnZW1lbnQiLAogICJwcml2YXRlX2tleV9pZCI6ICI0NWUzZGNkYmNlMTI0NGQ4MjA0ZTlkOTJmMjUzYWNkZmU4ZmFlNDIwIiwKICAicHJpdmF0ZV9rZXkiOiAiLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5NSUlFdmdJQkFEQU5CZ2txaGtpRzl3MEJBUUVGQUFTQ0JLZ3dnZ1NrQWdFQUFvSUJBUURsWG9FWVRZWkEyYlIwXG5Lc1l6NXJBRXd4aVVsMWtqUUlnd3dKdTA4NVdSRS9LZGxyZStHM0d1aUhGL1VNcGExb0JDU3NCUUN6d015aUVhXG5HWm4wS0hZRWw2NlVlQWxIN2NIa1JQRDRyeklhUVlFOXBaZVAzdkNjTXZzc3F1Y0tONFpiN0NSblozaDJ6am1FXG4vbVJHbUI1Y3p6b1lJVGpMSGdWUGdMdVIwZmF2Y2NtelFISzVJcmsrTEI0N0NZU3JHeEJsWU1sdTBUSis1SG1zXG5XS2g2V0xaRUowRTdUWUVmclpZUHdnVzF2UThPMGNQY0JsQ3NCNS9vVXhUaGxpR0FBSVVMZ0FzVnQra1NxZGIvXG5CMXBLWE5vQjlZQStjR1RuL3JPQzJtUGlGTlh0Q21tRVVEdEFEYlZUQjFlcjZ1QTVnaUpPVkpOUEk0NjI2cTRJXG4yWVRaK2ZEWkFnTUJBQUVDZ2dFQUVKdFVDM2RwZGY2Tk15LzVvNzViRmkwdDlXd3oxdnFRSWIrQVJveXdVbzI0XG5MakxBWVBKSnU5SkRMVzJqQ1Fxb3hLUDI0cFM5cDB0bk5DV1FZUDRnOVZvcUdMUE1NRG9GcE1wZERCUmJNMDEwXG4xd3JMTzI1dnkwOWhvaTFKaHhMWUZvVzFhUXN6eko1SHlkcEZWbHNqTDZNTldMdm5DY1hGcXFqdWE3TmhBZmkvXG5BTHgwWmlOV0E4dDJlVHBQRE40VFdmd1plS3dCMjRCamxtT1dpVW84NnI2Tk05cVk1U0ZPZGVNUjI4VFdpSFB5XG5GQWUyWHN4ZHFHRnhEbkd0bEhEUjR5MExIcFdvRXdCck9lVkNJV1dCMmVGcjlLemJ6b3JGV01pWUpISER6SjVrXG42T0pZcTZkeHJzanIwSWl3cm8zb1kwblRZbmd0VndXLzJ6N3BrSzBseFFLQmdRRDQ1VWcyZjh0V3o4Q3FubTFQXG5WZkFSWEliZ0R3R1hRY3NiWlpPaXlqbGxwKzhqUUVuclcrQXRCdkErV0ptaXlwa3VwTzhLSWRnTm5KZUVqWUMwXG5ndmhwOVB6OC9ONDVnRGhxWTdrZy9NbjBpRWhvQjhZdjRhQzUrWkF3VmpoeVJHNUV5U01QZVBheSt4SjI4QmwwXG4rZGtpYStrR1F6dDRzYU5MRHVPNTdoTUdXd0tCZ1FEcjZvb1BGcW94SE5HNnJyL294QlhqcllwNGFYOFZDS1VaXG5FWkJlbTA4b29oTWI5bUtOb2dTU0lwUmxTZVRnZGw3QWZGSndVeDJVdk1GNWtTdlFkL2N6aHpoc2xoVjdodnFPXG5oblJDSGFGbFg1cFVDbUYwVjZRUVUrOVoyWTVjdTIyM2R0d282eEhCOTVFSkFzcUFZbTFWRURha1pEKzJyWERoXG53MFFJVnl4VDJ3S0JnUURZTVdDZVpqYmJ1eTJoaG1kd2lEYzAvV2Z5YTFaNGFoL2dOa1VkSEFick5BbldWdHRlXG5Zcm5Bb3N3L2UzcElHeThmZ3doWGhycENhSWg4RE9XRTVsYWN3cjZWaVpnRjNrZ2lzV2gzTE56SFdURjdtM1VjXG4xaUZrS1ZDRGpBRVAwRHp2NXI1YmpEak5rcVNoUjBEMWlLaUE2N2JIUjlMNnJXSW5xbEZJaVhGaERRS0JnQkRMXG40QXgyOXFNTWhEb2dXdlY5a0hsblQ2YUhERTg1RUZnYWtnYktVL1oybzVtb0R4ZzZZc3pzdlZnQXpOVFdXS1NuXG50VTdkL0FzczdURjBqb1FISm9oNnBoL0ZCNzZGdTYwT0VaVHJleVEyakV4VGVPREpwSnFzL3l1TWU4QktiK05TXG5DUmE0dGNqemtWWHM1dklCRC91d2JkT0NkNjdQNW11TWRYbUtpMU5sQW9HQkFPUVJneGF0Rzh1dUd0MjRzL29xXG42WS9pb0ZxbUNzV0JkQ2kybWRJT0F5ZE9zWkRuRGNxMEltbVdEOTd3ajFqalZodFA3M2t6VUFrRlZhajFLVmEwXG5hZWlua0tKR0pTV3N4VEZ0U0VubXpQQThxN1UwUWh6RnhJd1pFTUs1SFpuMmtRRzQ0ZFAydCtZY2xlMXZ2OGtKXG5IcG1IbFpldmUzbU5NQjRxc1ArMTZnZm5cbi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS1cbiIsCiAgImNsaWVudF9lbWFpbCI6ICJmaXJlYmFzZS1hZG1pbnNkay10azdnaUBhcmZ5LXVzZXItbWFuYWdlbWVudC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsCiAgImNsaWVudF9pZCI6ICIxMDc5MzI3MDkyOTk5MzUyMzQ4ODkiLAogICJhdXRoX3VyaSI6ICJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9vYXV0aDIvYXV0aCIsCiAgInRva2VuX3VyaSI6ICJodHRwczovL29hdXRoMi5nb29nbGVhcGlzLmNvbS90b2tlbiIsCiAgImF1dGhfcHJvdmlkZXJfeDUwOV9jZXJ0X3VybCI6ICJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9vYXV0aDIvdjEvY2VydHMiLAogICJjbGllbnRfeDUwOV9jZXJ0X3VybCI6ICJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9yb2JvdC92MS9tZXRhZGF0YS94NTA5L2ZpcmViYXNlLWFkbWluc2RrLXRrN2dpJTQwYXJmeS11c2VyLW1hbmFnZW1lbnQuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLAogICJ1bml2ZXJzZV9kb21haW4iOiAiZ29vZ2xlYXBpcy5jb20iCn0K
|
| 35 |
+
|
| 36 |
+
# === API KEYS ===
|
| 37 |
+
# OpenAI API Keys (multiple keys separated by commas or newlines)
|
| 38 |
+
OPENAI_API_KEYS=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,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,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,sk-proj-FU70mLLdl3XOxcE2u3JnT3BlbkFJLyivVh91Dj79p1P2HzRP,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,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,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,
|
| 39 |
+
|
| 40 |
+
# Anthropic API Keys
|
| 41 |
+
ANTHROPIC_API_KEYS=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,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,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,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
|
| 42 |
+
|
| 43 |
+
# === DASHBOARD CONFIGURATION ===
|
| 44 |
BRAND_NAME=NyanProxy
|
| 45 |
BRAND_EMOJI=π±
|
| 46 |
+
DASHBOARD_TITLE=NyanProxy Dashboard
|
| 47 |
+
DASHBOARD_SUBTITLE=Purr-fect monitoring for your AI service proxy!
|
| 48 |
+
DASHBOARD_REFRESH_INTERVAL=30
|
| 49 |
|
| 50 |
+
# === DEPLOYMENT ===
|
| 51 |
+
PORT=7860
|
|
|
|
| 52 |
|
| 53 |
+
# Optional: Override automatic URL detection
|
| 54 |
+
# APP_URL=https://your-custom-domain.com
|
.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Keys (comma-separated for multiple keys)
|
| 2 |
+
OPENAI_API_KEYS=sk-key1,sk-key2,sk-key3
|
| 3 |
+
ANTHROPIC_API_KEYS=sk-ant-key1,sk-ant-key2
|
| 4 |
+
|
| 5 |
+
# Authentication
|
| 6 |
+
AUTH_MODE=none
|
| 7 |
+
PROXY_KEY=your-proxy-key
|
| 8 |
+
ADMIN_KEY=your-admin-key
|
| 9 |
+
|
| 10 |
+
# App Configuration
|
| 11 |
+
PORT=7860
|
| 12 |
+
DEBUG=False
|
| 13 |
+
FLASK_SECRET_KEY=your-secret-key-change-in-production
|
| 14 |
+
|
| 15 |
+
# Dashboard Customization
|
| 16 |
+
DASHBOARD_TITLE=NyanProxy Dashboard
|
| 17 |
+
BRAND_EMOJI=π±
|
| 18 |
+
BRAND_NAME=NyanProxy
|
| 19 |
+
BRAND_DESCRIPTION=Meow-nificent AI Proxy!
|
| 20 |
+
DASHBOARD_SUBTITLE=Purr-fect monitoring for your AI service proxy!
|
| 21 |
+
DASHBOARD_REFRESH_INTERVAL=30
|
| 22 |
+
|
| 23 |
+
# Firebase (optional)
|
| 24 |
+
FIREBASE_URL=https://your-project.firebaseio.com
|
| 25 |
+
FIREBASE_CREDENTIALS_JSON=path/to/credentials.json
|
.gitignore
CHANGED
|
@@ -79,4 +79,10 @@ htmlcov/
|
|
| 79 |
*.tmp
|
| 80 |
*.temp
|
| 81 |
temp/
|
| 82 |
-
tmp/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
*.tmp
|
| 80 |
*.temp
|
| 81 |
temp/
|
| 82 |
+
tmp/
|
| 83 |
+
|
| 84 |
+
# Database files
|
| 85 |
+
*.db
|
| 86 |
+
*.db-shm
|
| 87 |
+
*.db-wal
|
| 88 |
+
database/
|
AUTHENTICATION.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NyanProxy Authentication System
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
NyanProxy now includes a comprehensive authentication and user management system with dual authentication modes, usage tracking, and Firebase integration.
|
| 6 |
+
|
| 7 |
+
## π Authentication Modes
|
| 8 |
+
|
| 9 |
+
### 1. No Authentication (`AUTH_MODE=none`)
|
| 10 |
+
- No authentication required
|
| 11 |
+
- All requests are allowed
|
| 12 |
+
- No user tracking or quotas
|
| 13 |
+
|
| 14 |
+
### 2. Proxy Key Mode (`AUTH_MODE=proxy_key`)
|
| 15 |
+
- Single shared password for all users
|
| 16 |
+
- Set `PROXY_PASSWORD` environment variable
|
| 17 |
+
- IP-based rate limiting
|
| 18 |
+
- No individual user tracking
|
| 19 |
+
|
| 20 |
+
### 3. User Token Mode (`AUTH_MODE=user_token`)
|
| 21 |
+
- Individual UUID tokens for each user
|
| 22 |
+
- Complete user management with Firebase storage
|
| 23 |
+
- Per-user quotas and usage tracking
|
| 24 |
+
- IP address monitoring and limits
|
| 25 |
+
- Ban/unban functionality
|
| 26 |
+
|
| 27 |
+
## π Quick Setup
|
| 28 |
+
|
| 29 |
+
### 1. Install Dependencies
|
| 30 |
+
```bash
|
| 31 |
+
pip install -r requirements.txt
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### 2. Configure Environment Variables
|
| 35 |
+
Copy `.env.example` to `.env` and configure:
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
# Basic configuration
|
| 39 |
+
AUTH_MODE=user_token
|
| 40 |
+
ADMIN_KEY=your_admin_key_here
|
| 41 |
+
|
| 42 |
+
# Firebase (for user_token mode)
|
| 43 |
+
FIREBASE_URL=https://your-project-default-rtdb.firebaseio.com/
|
| 44 |
+
|
| 45 |
+
# API Keys
|
| 46 |
+
OPENAI_API_KEYS=sk-key1,sk-key2
|
| 47 |
+
ANTHROPIC_API_KEYS=sk-ant-key1,sk-ant-key2
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 3. Start the Server
|
| 51 |
+
```bash
|
| 52 |
+
python app.py
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## π Authentication Headers
|
| 56 |
+
|
| 57 |
+
### For Proxy Key Mode:
|
| 58 |
+
```bash
|
| 59 |
+
curl -H "Authorization: Bearer your_proxy_password" \
|
| 60 |
+
-H "Content-Type: application/json" \
|
| 61 |
+
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}' \
|
| 62 |
+
https://your-proxy.com/v1/chat/completions
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### For User Token Mode:
|
| 66 |
+
```bash
|
| 67 |
+
curl -H "Authorization: Bearer user_token_uuid" \
|
| 68 |
+
-H "Content-Type: application/json" \
|
| 69 |
+
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}' \
|
| 70 |
+
https://your-proxy.com/v1/chat/completions
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### Alternative Headers:
|
| 74 |
+
- `x-api-key: your_token`
|
| 75 |
+
- Query parameter: `?key=your_token`
|
| 76 |
+
|
| 77 |
+
## π¨βπΌ Admin Interface
|
| 78 |
+
|
| 79 |
+
### Access the Admin Dashboard
|
| 80 |
+
Visit `/admin/dashboard` in your browser and enter your admin key.
|
| 81 |
+
|
| 82 |
+
### Admin API Endpoints
|
| 83 |
+
|
| 84 |
+
#### User Management
|
| 85 |
+
- `GET /admin/users` - List all users
|
| 86 |
+
- `POST /admin/users` - Create new user
|
| 87 |
+
- `GET /admin/users/{token}` - Get user details
|
| 88 |
+
- `PUT /admin/users/{token}` - Update user
|
| 89 |
+
- `POST /admin/users/{token}/disable` - Ban user
|
| 90 |
+
- `POST /admin/users/{token}/enable` - Unban user
|
| 91 |
+
- `POST /admin/users/{token}/rotate` - Rotate token
|
| 92 |
+
- `DELETE /admin/users/{token}` - Delete user
|
| 93 |
+
|
| 94 |
+
#### Statistics
|
| 95 |
+
- `GET /admin/stats` - System statistics
|
| 96 |
+
- `GET /admin/events` - Event logs
|
| 97 |
+
|
| 98 |
+
#### Bulk Operations
|
| 99 |
+
- `POST /admin/bulk/refresh-quotas` - Reset all quotas
|
| 100 |
+
|
| 101 |
+
### Create User via API
|
| 102 |
+
```bash
|
| 103 |
+
curl -X POST \
|
| 104 |
+
-H "Authorization: Bearer your_admin_key" \
|
| 105 |
+
-H "Content-Type: application/json" \
|
| 106 |
+
-d '{
|
| 107 |
+
"type": "normal",
|
| 108 |
+
"nickname": "John Doe",
|
| 109 |
+
"token_limits": {
|
| 110 |
+
"openai": 100000,
|
| 111 |
+
"anthropic": 50000
|
| 112 |
+
}
|
| 113 |
+
}' \
|
| 114 |
+
https://your-proxy.com/admin/users
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
## π User Management Features
|
| 118 |
+
|
| 119 |
+
### User Types
|
| 120 |
+
- **Normal**: Standard users with default quotas
|
| 121 |
+
- **Special**: Premium users with higher limits and no rate limiting
|
| 122 |
+
- **Temporary**: Time-limited users (expire after 24 hours)
|
| 123 |
+
|
| 124 |
+
### User Properties
|
| 125 |
+
- **Token**: UUID-based authentication token
|
| 126 |
+
- **Nickname**: Human-readable identifier
|
| 127 |
+
- **Type**: User privilege level
|
| 128 |
+
- **IP Tracking**: Monitors IP addresses used
|
| 129 |
+
- **Usage Tracking**: Token consumption per model family
|
| 130 |
+
- **Quotas**: Configurable limits per model family
|
| 131 |
+
- **Ban System**: Disable with reasons
|
| 132 |
+
|
| 133 |
+
### Automatic Features
|
| 134 |
+
- **IP Limit Enforcement**: Auto-ban users with too many IPs
|
| 135 |
+
- **Quota Tracking**: Real-time token usage monitoring
|
| 136 |
+
- **Event Logging**: Comprehensive audit trail
|
| 137 |
+
- **Temporary User Cleanup**: Automatic expiration handling
|
| 138 |
+
|
| 139 |
+
## π Usage Tracking
|
| 140 |
+
|
| 141 |
+
### Token Counting
|
| 142 |
+
- Input tokens (prompt)
|
| 143 |
+
- Output tokens (completion)
|
| 144 |
+
- Total tokens
|
| 145 |
+
- Cost calculation in USD
|
| 146 |
+
|
| 147 |
+
### Per-Model Tracking
|
| 148 |
+
- OpenAI models (GPT-3.5, GPT-4, etc.)
|
| 149 |
+
- Anthropic models (Claude)
|
| 150 |
+
- Google AI models
|
| 151 |
+
- Mistral models
|
| 152 |
+
|
| 153 |
+
### Event Types
|
| 154 |
+
- `chat_completion`: API request completion
|
| 155 |
+
- `new_ip`: New IP address detected
|
| 156 |
+
- `user_action`: Administrative actions
|
| 157 |
+
|
| 158 |
+
## π₯ Firebase Integration
|
| 159 |
+
|
| 160 |
+
### Setup Firebase
|
| 161 |
+
1. Create a Firebase project
|
| 162 |
+
2. Enable Realtime Database
|
| 163 |
+
3. Create service account with database access
|
| 164 |
+
4. Download service account key JSON
|
| 165 |
+
|
| 166 |
+
### Configuration
|
| 167 |
+
```bash
|
| 168 |
+
# Firebase URL
|
| 169 |
+
FIREBASE_URL=https://your-project-default-rtdb.firebaseio.com/
|
| 170 |
+
|
| 171 |
+
# Service account key (JSON string)
|
| 172 |
+
FIREBASE_SERVICE_ACCOUNT_KEY='{"type":"service_account",...}'
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### Data Structure
|
| 176 |
+
```json
|
| 177 |
+
{
|
| 178 |
+
"users": {
|
| 179 |
+
"user_token_uuid": {
|
| 180 |
+
"token": "uuid",
|
| 181 |
+
"type": "normal",
|
| 182 |
+
"created_at": "2024-01-01T00:00:00",
|
| 183 |
+
"nickname": "John Doe",
|
| 184 |
+
"ip": ["hashed_ip_1", "hashed_ip_2"],
|
| 185 |
+
"token_counts": {
|
| 186 |
+
"openai": {"input": 1000, "output": 500, "total": 1500},
|
| 187 |
+
"anthropic": {"input": 800, "output": 400, "total": 1200}
|
| 188 |
+
},
|
| 189 |
+
"token_limits": {
|
| 190 |
+
"openai": 100000,
|
| 191 |
+
"anthropic": 50000
|
| 192 |
+
},
|
| 193 |
+
"disabled_at": null,
|
| 194 |
+
"disabled_reason": null
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
## π‘οΈ Security Features
|
| 201 |
+
|
| 202 |
+
### IP Privacy
|
| 203 |
+
- All IP addresses are SHA-256 hashed
|
| 204 |
+
- No raw IP storage
|
| 205 |
+
- Privacy-preserving tracking
|
| 206 |
+
|
| 207 |
+
### Token Security
|
| 208 |
+
- UUID-based tokens (unguessable)
|
| 209 |
+
- Token rotation capability
|
| 210 |
+
- Automatic cleanup
|
| 211 |
+
|
| 212 |
+
### Rate Limiting
|
| 213 |
+
- Per-user and per-IP limits
|
| 214 |
+
- Sliding window algorithm
|
| 215 |
+
- Configurable limits
|
| 216 |
+
|
| 217 |
+
### Admin Security
|
| 218 |
+
- Bearer token authentication
|
| 219 |
+
- Admin key rotation support
|
| 220 |
+
- Audit logging
|
| 221 |
+
|
| 222 |
+
## βοΈ Configuration Reference
|
| 223 |
+
|
| 224 |
+
### Authentication
|
| 225 |
+
```bash
|
| 226 |
+
AUTH_MODE=user_token # none, proxy_key, user_token
|
| 227 |
+
PROXY_PASSWORD=secure_password # For proxy_key mode
|
| 228 |
+
ADMIN_KEY=admin_key # Admin access key
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
### User Limits
|
| 232 |
+
```bash
|
| 233 |
+
MAX_IPS_PER_USER=3 # IP addresses per user
|
| 234 |
+
MAX_IPS_AUTO_BAN=true # Auto-ban on IP limit
|
| 235 |
+
RATE_LIMIT_PER_MINUTE=60 # Requests per minute
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### Firebase
|
| 239 |
+
```bash
|
| 240 |
+
FIREBASE_URL=https://... # Firebase Realtime Database URL
|
| 241 |
+
FIREBASE_SERVICE_ACCOUNT_KEY={} # Service account JSON
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
## π Monitoring & Analytics
|
| 245 |
+
|
| 246 |
+
### Dashboard Features
|
| 247 |
+
- Real-time user statistics
|
| 248 |
+
- Usage analytics
|
| 249 |
+
- Token consumption tracking
|
| 250 |
+
- Cost monitoring
|
| 251 |
+
- Event logging
|
| 252 |
+
|
| 253 |
+
### Admin Tools
|
| 254 |
+
- User creation and management
|
| 255 |
+
- Bulk operations
|
| 256 |
+
- Usage reports
|
| 257 |
+
- Event filtering
|
| 258 |
+
|
| 259 |
+
## π¨ Troubleshooting
|
| 260 |
+
|
| 261 |
+
### Common Issues
|
| 262 |
+
|
| 263 |
+
1. **Firebase Connection Failed**
|
| 264 |
+
- Check Firebase URL format
|
| 265 |
+
- Verify service account permissions
|
| 266 |
+
- Ensure database rules allow read/write
|
| 267 |
+
|
| 268 |
+
2. **Authentication Errors**
|
| 269 |
+
- Verify AUTH_MODE setting
|
| 270 |
+
- Check token format
|
| 271 |
+
- Ensure admin key is correct
|
| 272 |
+
|
| 273 |
+
3. **Rate Limiting Issues**
|
| 274 |
+
- Check RATE_LIMIT_PER_MINUTE setting
|
| 275 |
+
- Verify user quotas
|
| 276 |
+
- Monitor IP limits
|
| 277 |
+
|
| 278 |
+
### Debug Mode
|
| 279 |
+
Set `debug=True` in `app.run()` for detailed error messages.
|
| 280 |
+
|
| 281 |
+
## π Performance Considerations
|
| 282 |
+
|
| 283 |
+
### Memory Usage
|
| 284 |
+
- In-memory user cache with Firebase sync
|
| 285 |
+
- Automatic cleanup of old data
|
| 286 |
+
- Efficient token counting
|
| 287 |
+
|
| 288 |
+
### Database Optimization
|
| 289 |
+
- Batch Firebase operations
|
| 290 |
+
- Background sync thread
|
| 291 |
+
- Minimal data structure
|
| 292 |
+
|
| 293 |
+
### Scalability
|
| 294 |
+
- Stateless authentication
|
| 295 |
+
- Horizontal scaling support
|
| 296 |
+
- Firebase handles concurrent access
|
| 297 |
+
|
| 298 |
+
## π― Best Practices
|
| 299 |
+
|
| 300 |
+
1. **Regular Backups**: Export user data periodically
|
| 301 |
+
2. **Monitor Usage**: Track token consumption and costs
|
| 302 |
+
3. **Rotate Keys**: Regularly rotate admin and user tokens
|
| 303 |
+
4. **Audit Logs**: Review event logs for suspicious activity
|
| 304 |
+
5. **Quota Management**: Set appropriate limits for user types
|
| 305 |
+
|
| 306 |
+
## π API Examples
|
| 307 |
+
|
| 308 |
+
### Create User
|
| 309 |
+
```python
|
| 310 |
+
import requests
|
| 311 |
+
|
| 312 |
+
response = requests.post('https://your-proxy.com/admin/users',
|
| 313 |
+
headers={'Authorization': 'Bearer admin_key'},
|
| 314 |
+
json={
|
| 315 |
+
'type': 'normal',
|
| 316 |
+
'nickname': 'Test User',
|
| 317 |
+
'token_limits': {'openai': 50000}
|
| 318 |
+
})
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
### Check Usage
|
| 322 |
+
```python
|
| 323 |
+
response = requests.get('https://your-proxy.com/admin/users/user_token',
|
| 324 |
+
headers={'Authorization': 'Bearer admin_key'})
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
### Disable User
|
| 328 |
+
```python
|
| 329 |
+
response = requests.post('https://your-proxy.com/admin/users/user_token/disable',
|
| 330 |
+
headers={'Authorization': 'Bearer admin_key'},
|
| 331 |
+
json={'reason': 'Abuse detected'})
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
This authentication system provides enterprise-grade user management with comprehensive tracking, Firebase integration, and powerful admin tools.
|
README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
A
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
|
|
|
| 1 |
---
|
| 2 |
+
title: NyanProxy
|
| 3 |
+
emoji: π±
|
| 4 |
+
colorFrom: pink
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# π± NyanProxy - Meow-nificent AI Proxy!
|
| 12 |
|
| 13 |
+
A sophisticated AI proxy service that provides unified access to multiple AI providers with advanced features like token tracking, rate limiting, and user management.
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
{tokenizers β ai/tokenizers}/__init__.py
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/anthropic_tokenizer.py
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/claude.ts
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/index.ts
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/mistral-tokenizer-js.ts
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/mistral.ts
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/openai.ts
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/openai_tokenizer.py
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/tokenizer.ts
RENAMED
|
File without changes
|
{tokenizers β ai/tokenizers}/unified_tokenizer.py
RENAMED
|
File without changes
|
app.py
CHANGED
|
@@ -1,901 +1,30 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
| 3 |
import os
|
| 4 |
-
import json
|
| 5 |
-
import random
|
| 6 |
-
import time
|
| 7 |
-
import threading
|
| 8 |
-
from typing import Dict, List
|
| 9 |
-
from datetime import datetime, timedelta
|
| 10 |
-
from health_checker import health_manager, HealthResult
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
import tiktoken
|
| 15 |
-
TIKTOKEN_AVAILABLE = True
|
| 16 |
-
except ImportError:
|
| 17 |
-
TIKTOKEN_AVAILABLE = False
|
| 18 |
-
print("Warning: tiktoken not available, token counting will be approximate")
|
| 19 |
|
| 20 |
-
#
|
| 21 |
-
|
| 22 |
-
from dotenv import load_dotenv
|
| 23 |
-
load_dotenv()
|
| 24 |
-
except ImportError:
|
| 25 |
-
# If python-dotenv is not installed, continue without it
|
| 26 |
-
pass
|
| 27 |
-
|
| 28 |
-
from tokenizers.unified_tokenizer import unified_tokenizer
|
| 29 |
-
|
| 30 |
-
class MetricsTracker:
|
| 31 |
-
def __init__(self):
|
| 32 |
-
self.start_time = time.time()
|
| 33 |
-
self.request_counts = {
|
| 34 |
-
'chat_completions': 0,
|
| 35 |
-
'models': 0,
|
| 36 |
-
'health': 0,
|
| 37 |
-
'key_status': 0
|
| 38 |
-
}
|
| 39 |
-
self.error_counts = {
|
| 40 |
-
'chat_completions': 0,
|
| 41 |
-
'models': 0,
|
| 42 |
-
'key_errors': 0
|
| 43 |
-
}
|
| 44 |
-
self.total_requests = 0
|
| 45 |
-
self.last_request_time = None
|
| 46 |
-
self.response_times = []
|
| 47 |
-
# Token tracking
|
| 48 |
-
self.total_tokens = 0
|
| 49 |
-
self.prompt_tokens = 0
|
| 50 |
-
self.completion_tokens = 0
|
| 51 |
-
# Per-service token tracking
|
| 52 |
-
self.service_tokens = {
|
| 53 |
-
'openai': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0, 'response_times': []},
|
| 54 |
-
'anthropic': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0, 'response_times': []}
|
| 55 |
-
}
|
| 56 |
-
self.lock = threading.Lock()
|
| 57 |
-
|
| 58 |
-
def track_request(self, endpoint: str, response_time: float = None, error: bool = False, tokens: dict = None, service: str = None):
|
| 59 |
-
with self.lock:
|
| 60 |
-
self.total_requests += 1
|
| 61 |
-
self.last_request_time = datetime.now()
|
| 62 |
-
|
| 63 |
-
if endpoint in self.request_counts:
|
| 64 |
-
self.request_counts[endpoint] += 1
|
| 65 |
-
|
| 66 |
-
if error and endpoint in self.error_counts:
|
| 67 |
-
self.error_counts[endpoint] += 1
|
| 68 |
-
|
| 69 |
-
if response_time is not None:
|
| 70 |
-
self.response_times.append(response_time)
|
| 71 |
-
# Keep only last 100 response times
|
| 72 |
-
if len(self.response_times) > 100:
|
| 73 |
-
self.response_times.pop(0)
|
| 74 |
-
|
| 75 |
-
# Track tokens
|
| 76 |
-
if tokens and service:
|
| 77 |
-
prompt_tokens = tokens.get('prompt_tokens', 0)
|
| 78 |
-
completion_tokens = tokens.get('completion_tokens', 0)
|
| 79 |
-
total_tokens = tokens.get('total_tokens', 0)
|
| 80 |
-
|
| 81 |
-
# Update global counters
|
| 82 |
-
self.prompt_tokens += prompt_tokens
|
| 83 |
-
self.completion_tokens += completion_tokens
|
| 84 |
-
self.total_tokens += total_tokens
|
| 85 |
-
|
| 86 |
-
# Update per-service counters
|
| 87 |
-
if service in self.service_tokens:
|
| 88 |
-
self.service_tokens[service]['prompt_tokens'] += prompt_tokens
|
| 89 |
-
self.service_tokens[service]['completion_tokens'] += completion_tokens
|
| 90 |
-
self.service_tokens[service]['total_tokens'] += total_tokens
|
| 91 |
-
|
| 92 |
-
# Track response times per service
|
| 93 |
-
if response_time is not None:
|
| 94 |
-
self.service_tokens[service]['response_times'].append(response_time)
|
| 95 |
-
# Keep only last 100 response times per service
|
| 96 |
-
if len(self.service_tokens[service]['response_times']) > 100:
|
| 97 |
-
self.service_tokens[service]['response_times'].pop(0)
|
| 98 |
-
|
| 99 |
-
def get_uptime(self):
|
| 100 |
-
return time.time() - self.start_time
|
| 101 |
-
|
| 102 |
-
def get_average_response_time(self):
|
| 103 |
-
if not self.response_times:
|
| 104 |
-
return 0
|
| 105 |
-
return sum(self.response_times) / len(self.response_times)
|
| 106 |
-
|
| 107 |
-
def get_metrics(self):
|
| 108 |
-
with self.lock:
|
| 109 |
-
return {
|
| 110 |
-
'uptime_seconds': self.get_uptime(),
|
| 111 |
-
'total_requests': self.total_requests,
|
| 112 |
-
'request_counts': self.request_counts.copy(),
|
| 113 |
-
'error_counts': self.error_counts.copy(),
|
| 114 |
-
'last_request': self.last_request_time.isoformat() if self.last_request_time else None,
|
| 115 |
-
'average_response_time': self.get_average_response_time(),
|
| 116 |
-
'total_tokens': self.total_tokens,
|
| 117 |
-
'prompt_tokens': self.prompt_tokens,
|
| 118 |
-
'completion_tokens': self.completion_tokens,
|
| 119 |
-
'service_tokens': self.service_tokens.copy()
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
metrics = MetricsTracker()
|
| 123 |
-
app = Flask(__name__)
|
| 124 |
-
|
| 125 |
-
class APIKeyManager:
|
| 126 |
-
def __init__(self):
|
| 127 |
-
self.api_keys = self._load_api_keys()
|
| 128 |
-
self.key_usage = {} # Track usage per key for rate limiting
|
| 129 |
-
self.failed_keys = set() # Track temporarily failed keys
|
| 130 |
-
self.key_health = {} # Track detailed health status for each key
|
| 131 |
-
self.lock = threading.Lock() # Thread safety for key health updates
|
| 132 |
-
self._initialize_key_health()
|
| 133 |
-
|
| 134 |
-
def _parse_keys(self, key_string: str) -> List[str]:
|
| 135 |
-
"""Parse comma and line-separated keys"""
|
| 136 |
-
if not key_string:
|
| 137 |
-
return []
|
| 138 |
-
|
| 139 |
-
keys = []
|
| 140 |
-
# Split by both commas and newlines
|
| 141 |
-
for line in key_string.replace(',', '\n').split('\n'):
|
| 142 |
-
key = line.strip()
|
| 143 |
-
if key and key not in keys:
|
| 144 |
-
keys.append(key)
|
| 145 |
-
return keys
|
| 146 |
-
|
| 147 |
-
def _load_api_keys(self) -> Dict[str, List[str]]:
|
| 148 |
-
"""Load API keys from environment variables or config"""
|
| 149 |
-
keys = {}
|
| 150 |
-
|
| 151 |
-
# OpenAI keys - support multiple formats
|
| 152 |
-
openai_keys = []
|
| 153 |
-
|
| 154 |
-
# Try comma/line separated format first
|
| 155 |
-
bulk_key = os.getenv('OPENAI_API_KEYS')
|
| 156 |
-
if bulk_key:
|
| 157 |
-
parsed_keys = self._parse_keys(bulk_key)
|
| 158 |
-
print(f"DEBUG: Found {len(parsed_keys)} OpenAI keys from OPENAI_API_KEYS")
|
| 159 |
-
openai_keys.extend(parsed_keys)
|
| 160 |
-
|
| 161 |
-
# Try numbered format
|
| 162 |
-
i = 1
|
| 163 |
-
while True:
|
| 164 |
-
key = os.getenv(f'OPENAI_API_KEY_{i}')
|
| 165 |
-
if key:
|
| 166 |
-
openai_keys.extend(self._parse_keys(key))
|
| 167 |
-
i += 1
|
| 168 |
-
else:
|
| 169 |
-
break
|
| 170 |
-
|
| 171 |
-
# Fallback to single key
|
| 172 |
-
if not openai_keys:
|
| 173 |
-
single_key = os.getenv('OPENAI_API_KEY')
|
| 174 |
-
if single_key:
|
| 175 |
-
openai_keys.extend(self._parse_keys(single_key))
|
| 176 |
-
|
| 177 |
-
keys['openai'] = list(set(openai_keys)) # Remove duplicates
|
| 178 |
-
|
| 179 |
-
# Anthropic keys
|
| 180 |
-
anthropic_keys = []
|
| 181 |
-
bulk_key = os.getenv('ANTHROPIC_API_KEYS')
|
| 182 |
-
if bulk_key:
|
| 183 |
-
anthropic_keys.extend(self._parse_keys(bulk_key))
|
| 184 |
-
|
| 185 |
-
single_key = os.getenv('ANTHROPIC_API_KEY')
|
| 186 |
-
if single_key:
|
| 187 |
-
anthropic_keys.extend(self._parse_keys(single_key))
|
| 188 |
-
|
| 189 |
-
keys['anthropic'] = list(set(anthropic_keys))
|
| 190 |
-
|
| 191 |
-
return keys
|
| 192 |
-
|
| 193 |
-
def _initialize_key_health(self):
|
| 194 |
-
"""Initialize health tracking for all keys"""
|
| 195 |
-
with self.lock:
|
| 196 |
-
for service, keys in self.api_keys.items():
|
| 197 |
-
for key in keys:
|
| 198 |
-
if key and key not in self.key_health:
|
| 199 |
-
self.key_health[key] = {
|
| 200 |
-
'status': 'unknown',
|
| 201 |
-
'service': service,
|
| 202 |
-
'last_error': None,
|
| 203 |
-
'last_success': None,
|
| 204 |
-
'last_failure': None,
|
| 205 |
-
'failure_count': 0,
|
| 206 |
-
'consecutive_failures': 0,
|
| 207 |
-
'error_type': None
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
def perform_proactive_health_check(self, service: str, api_key: str):
|
| 211 |
-
"""Perform proactive health check using the health checker system"""
|
| 212 |
-
try:
|
| 213 |
-
result = health_manager.check_service_health(service, api_key)
|
| 214 |
-
|
| 215 |
-
# Update key health based on result
|
| 216 |
-
if result.status == 'healthy':
|
| 217 |
-
self.update_key_health(api_key, True)
|
| 218 |
-
else:
|
| 219 |
-
self.update_key_health(
|
| 220 |
-
api_key,
|
| 221 |
-
False,
|
| 222 |
-
result.status_code,
|
| 223 |
-
result.error_message
|
| 224 |
-
)
|
| 225 |
-
|
| 226 |
-
except Exception as e:
|
| 227 |
-
# If health check fails, mark as network error
|
| 228 |
-
self.update_key_health(api_key, False, None, f"Health check failed: {str(e)}")
|
| 229 |
-
|
| 230 |
-
def run_initial_health_checks(self):
|
| 231 |
-
"""Run initial health checks for all keys in background"""
|
| 232 |
-
def check_all_keys():
|
| 233 |
-
print("Starting initial health checks for all API keys...")
|
| 234 |
-
for service, keys in self.api_keys.items():
|
| 235 |
-
for key in keys:
|
| 236 |
-
if key:
|
| 237 |
-
print(f"Checking {service} key: {key[:8]}...")
|
| 238 |
-
self.perform_proactive_health_check(service, key)
|
| 239 |
-
time.sleep(0.5) # Small delay to avoid overwhelming APIs
|
| 240 |
-
print("Initial health checks completed.")
|
| 241 |
-
|
| 242 |
-
# Run in background thread
|
| 243 |
-
thread = threading.Thread(target=check_all_keys, daemon=True)
|
| 244 |
-
thread.start()
|
| 245 |
-
|
| 246 |
-
def _classify_error(self, status_code: int, error_message: str) -> tuple:
|
| 247 |
-
"""Classify error type and return (error_type, is_retryable)"""
|
| 248 |
-
error_msg_lower = error_message.lower()
|
| 249 |
-
|
| 250 |
-
# Authentication errors (not retryable with same key)
|
| 251 |
-
if status_code in [401, 403]:
|
| 252 |
-
return 'invalid_key', False
|
| 253 |
-
|
| 254 |
-
# Rate limiting (retryable)
|
| 255 |
-
if status_code == 429:
|
| 256 |
-
return 'rate_limited', True
|
| 257 |
-
|
| 258 |
-
# Quota/billing issues
|
| 259 |
-
quota_indicators = ['quota exceeded', 'billing', 'insufficient_quota', 'usage limit']
|
| 260 |
-
if any(indicator in error_msg_lower for indicator in quota_indicators):
|
| 261 |
-
return 'quota_exceeded', False
|
| 262 |
-
|
| 263 |
-
# Rate limit indicators in message
|
| 264 |
-
rate_limit_indicators = [
|
| 265 |
-
'rate limit', 'too many requests', 'rate_limit_exceeded',
|
| 266 |
-
'requests per minute', 'rpm', 'tpm'
|
| 267 |
-
]
|
| 268 |
-
if any(indicator in error_msg_lower for indicator in rate_limit_indicators):
|
| 269 |
-
return 'rate_limited', True
|
| 270 |
-
|
| 271 |
-
# Server errors (potentially retryable)
|
| 272 |
-
if status_code >= 500:
|
| 273 |
-
return 'server_error', True
|
| 274 |
-
|
| 275 |
-
# Other client errors
|
| 276 |
-
if status_code >= 400:
|
| 277 |
-
return 'client_error', False
|
| 278 |
-
|
| 279 |
-
return 'unknown_error', False
|
| 280 |
-
|
| 281 |
-
def get_api_key(self, service: str, exclude_failed: bool = True) -> str:
|
| 282 |
-
"""Get next available API key for the service with rate limit handling"""
|
| 283 |
-
if service not in self.api_keys or not self.api_keys[service]:
|
| 284 |
-
return None
|
| 285 |
-
|
| 286 |
-
available_keys = [key for key in self.api_keys[service] if key]
|
| 287 |
-
|
| 288 |
-
# Remove failed keys if requested
|
| 289 |
-
if exclude_failed:
|
| 290 |
-
available_keys = [key for key in available_keys if key not in self.failed_keys]
|
| 291 |
-
|
| 292 |
-
if not available_keys:
|
| 293 |
-
# If all keys failed, try again with failed keys (maybe they recovered)
|
| 294 |
-
if exclude_failed:
|
| 295 |
-
return self.get_api_key(service, exclude_failed=False)
|
| 296 |
-
return None
|
| 297 |
-
|
| 298 |
-
# Simple round-robin selection
|
| 299 |
-
if service not in self.key_usage:
|
| 300 |
-
self.key_usage[service] = 0
|
| 301 |
-
|
| 302 |
-
key_index = self.key_usage[service] % len(available_keys)
|
| 303 |
-
self.key_usage[service] += 1
|
| 304 |
-
|
| 305 |
-
return available_keys[key_index]
|
| 306 |
-
|
| 307 |
-
def update_key_health(self, key: str, success: bool, status_code: int = None, error_message: str = None):
|
| 308 |
-
"""Update key health status based on API response"""
|
| 309 |
-
with self.lock:
|
| 310 |
-
if key not in self.key_health:
|
| 311 |
-
# Initialize if not exists
|
| 312 |
-
service = next((svc for svc, keys in self.api_keys.items() if key in keys), 'unknown')
|
| 313 |
-
self.key_health[key] = {
|
| 314 |
-
'status': 'unknown',
|
| 315 |
-
'service': service,
|
| 316 |
-
'last_error': None,
|
| 317 |
-
'last_success': None,
|
| 318 |
-
'last_failure': None,
|
| 319 |
-
'failure_count': 0,
|
| 320 |
-
'consecutive_failures': 0,
|
| 321 |
-
'error_type': None
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
-
health = self.key_health[key]
|
| 325 |
-
now = datetime.now()
|
| 326 |
-
|
| 327 |
-
if success:
|
| 328 |
-
health['status'] = 'healthy'
|
| 329 |
-
health['last_success'] = now
|
| 330 |
-
health['consecutive_failures'] = 0
|
| 331 |
-
health['error_type'] = None
|
| 332 |
-
health['last_error'] = None
|
| 333 |
-
else:
|
| 334 |
-
health['last_failure'] = now
|
| 335 |
-
health['failure_count'] += 1
|
| 336 |
-
health['consecutive_failures'] += 1
|
| 337 |
-
|
| 338 |
-
if status_code and error_message:
|
| 339 |
-
error_type, is_retryable = self._classify_error(status_code, error_message)
|
| 340 |
-
health['error_type'] = error_type
|
| 341 |
-
health['status'] = error_type
|
| 342 |
-
health['last_error'] = error_message
|
| 343 |
-
|
| 344 |
-
# Only mark as failed for retryable errors
|
| 345 |
-
if is_retryable:
|
| 346 |
-
self.failed_keys.add(key)
|
| 347 |
-
# Auto-recovery after some time
|
| 348 |
-
threading.Timer(300, lambda: self.failed_keys.discard(key)).start()
|
| 349 |
-
else:
|
| 350 |
-
health['status'] = 'failed'
|
| 351 |
-
health['error_type'] = 'unknown_error'
|
| 352 |
-
|
| 353 |
-
def mark_key_failed(self, service: str, key: str):
|
| 354 |
-
"""Mark a key as temporarily failed (legacy method)"""
|
| 355 |
-
self.failed_keys.add(key)
|
| 356 |
-
# Auto-recovery after some time (simplified)
|
| 357 |
-
threading.Timer(300, lambda: self.failed_keys.discard(key)).start()
|
| 358 |
-
|
| 359 |
-
def handle_api_error(self, service: str, key: str, error_message: str, status_code: int = None) -> bool:
|
| 360 |
-
"""Handle API error and return True if should retry with different key"""
|
| 361 |
-
# Update key health with failure
|
| 362 |
-
self.update_key_health(key, False, status_code, error_message)
|
| 363 |
-
|
| 364 |
-
# Check if error is retryable
|
| 365 |
-
if status_code and error_message:
|
| 366 |
-
error_type, is_retryable = self._classify_error(status_code, error_message)
|
| 367 |
-
return is_retryable
|
| 368 |
-
|
| 369 |
-
# Legacy fallback for rate limiting
|
| 370 |
-
rate_limit_indicators = [
|
| 371 |
-
'rate limit', 'too many requests', 'quota exceeded',
|
| 372 |
-
'rate_limit_exceeded', 'requests per minute', 'rpm', 'tpm'
|
| 373 |
-
]
|
| 374 |
-
if any(indicator in error_message.lower() for indicator in rate_limit_indicators):
|
| 375 |
-
self.mark_key_failed(service, key)
|
| 376 |
-
return True
|
| 377 |
-
|
| 378 |
-
return False
|
| 379 |
-
|
| 380 |
-
key_manager = APIKeyManager()
|
| 381 |
-
metrics = MetricsTracker()
|
| 382 |
-
app = Flask(__name__)
|
| 383 |
-
|
| 384 |
-
# Run initial health checks in background
|
| 385 |
-
key_manager.run_initial_health_checks()
|
| 386 |
-
|
| 387 |
-
@app.route('/health', methods=['GET'])
|
| 388 |
-
def health_check():
|
| 389 |
-
start_time = time.time()
|
| 390 |
-
result = jsonify({"status": "healthy", "service": "AI Proxy"})
|
| 391 |
-
metrics.track_request('health', time.time() - start_time)
|
| 392 |
-
return result
|
| 393 |
-
|
| 394 |
-
@app.route('/openai/v1/chat/completions', methods=['POST'])
|
| 395 |
-
@app.route('/v1/chat/completions', methods=['POST']) # Legacy support
|
| 396 |
-
def openai_chat_completions():
|
| 397 |
-
"""Proxy OpenAI chat completions with rate limit handling"""
|
| 398 |
-
start_time = time.time()
|
| 399 |
-
max_retries = 3
|
| 400 |
-
|
| 401 |
-
for attempt in range(max_retries):
|
| 402 |
-
api_key = key_manager.get_api_key('openai')
|
| 403 |
-
if not api_key:
|
| 404 |
-
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 405 |
-
return jsonify({"error": "No OpenAI API key configured"}), 500
|
| 406 |
-
|
| 407 |
-
headers = {
|
| 408 |
-
'Authorization': f'Bearer {api_key}',
|
| 409 |
-
'Content-Type': 'application/json'
|
| 410 |
-
}
|
| 411 |
-
|
| 412 |
-
try:
|
| 413 |
-
response = requests.post(
|
| 414 |
-
'https://api.openai.com/v1/chat/completions',
|
| 415 |
-
headers=headers,
|
| 416 |
-
json=request.json,
|
| 417 |
-
stream=request.json.get('stream', False)
|
| 418 |
-
)
|
| 419 |
-
|
| 420 |
-
response_time = time.time() - start_time
|
| 421 |
-
|
| 422 |
-
# Handle different response codes
|
| 423 |
-
if response.status_code >= 400:
|
| 424 |
-
error_text = response.text
|
| 425 |
-
if key_manager.handle_api_error('openai', api_key, error_text, response.status_code):
|
| 426 |
-
if attempt < max_retries - 1:
|
| 427 |
-
time.sleep(1) # Brief pause before retry
|
| 428 |
-
continue
|
| 429 |
-
else:
|
| 430 |
-
# Success - update key health
|
| 431 |
-
key_manager.update_key_health(api_key, True)
|
| 432 |
-
|
| 433 |
-
# Extract token information from response
|
| 434 |
-
tokens = None
|
| 435 |
-
response_content = response.content
|
| 436 |
-
request_json = request.get_json() if request else {}
|
| 437 |
-
is_streaming = request_json.get('stream', False) if request_json else False
|
| 438 |
-
|
| 439 |
-
if response.status_code == 200:
|
| 440 |
-
model = request_json.get('model', 'gpt-3.5-turbo')
|
| 441 |
-
|
| 442 |
-
# For streaming requests, count prompt tokens only
|
| 443 |
-
if is_streaming:
|
| 444 |
-
try:
|
| 445 |
-
token_result = unified_tokenizer.count_tokens(
|
| 446 |
-
request_data=request_json,
|
| 447 |
-
service="openai",
|
| 448 |
-
model=model
|
| 449 |
-
)
|
| 450 |
-
tokens = {
|
| 451 |
-
'prompt_tokens': token_result['prompt_tokens'],
|
| 452 |
-
'completion_tokens': 0, # Can't count completion tokens for streaming
|
| 453 |
-
'total_tokens': token_result['prompt_tokens']
|
| 454 |
-
}
|
| 455 |
-
except Exception:
|
| 456 |
-
pass
|
| 457 |
-
else:
|
| 458 |
-
# Non-streaming request - try to get full token counts
|
| 459 |
-
try:
|
| 460 |
-
response_data = json.loads(response_content)
|
| 461 |
-
if 'usage' in response_data:
|
| 462 |
-
tokens = response_data['usage']
|
| 463 |
-
else:
|
| 464 |
-
# Fallback: estimate tokens using advanced tokenizer
|
| 465 |
-
response_text = ""
|
| 466 |
-
if 'choices' in response_data:
|
| 467 |
-
for choice in response_data['choices']:
|
| 468 |
-
if 'message' in choice and 'content' in choice['message']:
|
| 469 |
-
response_text += choice['message']['content'] + " "
|
| 470 |
-
|
| 471 |
-
token_result = unified_tokenizer.count_tokens(
|
| 472 |
-
request_data=request_json,
|
| 473 |
-
service="openai",
|
| 474 |
-
model=model,
|
| 475 |
-
response_text=response_text.strip()
|
| 476 |
-
)
|
| 477 |
-
tokens = {
|
| 478 |
-
'prompt_tokens': token_result['prompt_tokens'],
|
| 479 |
-
'completion_tokens': token_result['completion_tokens'],
|
| 480 |
-
'total_tokens': token_result['total_tokens']
|
| 481 |
-
}
|
| 482 |
-
except (json.JSONDecodeError, Exception):
|
| 483 |
-
# Still try to count tokens from request at least
|
| 484 |
-
try:
|
| 485 |
-
token_result = unified_tokenizer.count_tokens(
|
| 486 |
-
request_data=request_json,
|
| 487 |
-
service="openai",
|
| 488 |
-
model=model
|
| 489 |
-
)
|
| 490 |
-
tokens = {
|
| 491 |
-
'prompt_tokens': token_result['prompt_tokens'],
|
| 492 |
-
'completion_tokens': 0,
|
| 493 |
-
'total_tokens': token_result['prompt_tokens']
|
| 494 |
-
}
|
| 495 |
-
except Exception:
|
| 496 |
-
pass
|
| 497 |
-
|
| 498 |
-
metrics.track_request('chat_completions', response_time, error=response.status_code >= 400, tokens=tokens, service='openai')
|
| 499 |
-
|
| 500 |
-
if is_streaming:
|
| 501 |
-
return Response(
|
| 502 |
-
response.iter_content(chunk_size=1024),
|
| 503 |
-
content_type=response.headers.get('content-type'),
|
| 504 |
-
status=response.status_code
|
| 505 |
-
)
|
| 506 |
-
else:
|
| 507 |
-
return Response(
|
| 508 |
-
response_content,
|
| 509 |
-
content_type=response.headers.get('content-type'),
|
| 510 |
-
status=response.status_code
|
| 511 |
-
)
|
| 512 |
-
|
| 513 |
-
except Exception as e:
|
| 514 |
-
error_msg = str(e)
|
| 515 |
-
if key_manager.handle_api_error('openai', api_key, error_msg):
|
| 516 |
-
if attempt < max_retries - 1:
|
| 517 |
-
time.sleep(1)
|
| 518 |
-
continue
|
| 519 |
-
|
| 520 |
-
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 521 |
-
return jsonify({"error": error_msg}), 500
|
| 522 |
-
|
| 523 |
-
# If we get here, all retries failed
|
| 524 |
-
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 525 |
-
return jsonify({"error": "All API keys rate limited or failed"}), 429
|
| 526 |
-
|
| 527 |
-
@app.route('/openai/v1/models', methods=['GET'])
|
| 528 |
-
@app.route('/v1/models', methods=['GET']) # Legacy support
|
| 529 |
-
def openai_models():
|
| 530 |
-
"""Proxy OpenAI models endpoint with rate limit handling"""
|
| 531 |
-
start_time = time.time()
|
| 532 |
-
max_retries = 3
|
| 533 |
-
|
| 534 |
-
for attempt in range(max_retries):
|
| 535 |
-
api_key = key_manager.get_api_key('openai')
|
| 536 |
-
if not api_key:
|
| 537 |
-
metrics.track_request('models', time.time() - start_time, error=True)
|
| 538 |
-
return jsonify({"error": "No OpenAI API key configured"}), 500
|
| 539 |
-
|
| 540 |
-
headers = {
|
| 541 |
-
'Authorization': f'Bearer {api_key}',
|
| 542 |
-
'Content-Type': 'application/json'
|
| 543 |
-
}
|
| 544 |
-
|
| 545 |
-
try:
|
| 546 |
-
response = requests.get(
|
| 547 |
-
'https://api.openai.com/v1/models',
|
| 548 |
-
headers=headers
|
| 549 |
-
)
|
| 550 |
-
|
| 551 |
-
response_time = time.time() - start_time
|
| 552 |
-
|
| 553 |
-
# Handle different response codes
|
| 554 |
-
if response.status_code >= 400:
|
| 555 |
-
error_text = response.text
|
| 556 |
-
if key_manager.handle_api_error('openai', api_key, error_text, response.status_code):
|
| 557 |
-
if attempt < max_retries - 1:
|
| 558 |
-
time.sleep(1)
|
| 559 |
-
continue
|
| 560 |
-
else:
|
| 561 |
-
# Success - update key health
|
| 562 |
-
key_manager.update_key_health(api_key, True)
|
| 563 |
-
|
| 564 |
-
metrics.track_request('models', response_time, error=response.status_code >= 400)
|
| 565 |
-
|
| 566 |
-
return Response(
|
| 567 |
-
response.content,
|
| 568 |
-
content_type=response.headers.get('content-type'),
|
| 569 |
-
status=response.status_code
|
| 570 |
-
)
|
| 571 |
-
|
| 572 |
-
except Exception as e:
|
| 573 |
-
error_msg = str(e)
|
| 574 |
-
if key_manager.handle_api_error('openai', api_key, error_msg):
|
| 575 |
-
if attempt < max_retries - 1:
|
| 576 |
-
time.sleep(1)
|
| 577 |
-
continue
|
| 578 |
-
|
| 579 |
-
metrics.track_request('models', time.time() - start_time, error=True)
|
| 580 |
-
return jsonify({"error": error_msg}), 500
|
| 581 |
-
|
| 582 |
-
# If we get here, all retries failed
|
| 583 |
-
metrics.track_request('models', time.time() - start_time, error=True)
|
| 584 |
-
return jsonify({"error": "All API keys rate limited or failed"}), 429
|
| 585 |
-
|
| 586 |
-
@app.route('/api/keys/status', methods=['GET'])
|
| 587 |
-
def key_status():
|
| 588 |
-
"""Check which API keys are configured"""
|
| 589 |
-
start_time = time.time()
|
| 590 |
-
status = {}
|
| 591 |
-
for service, keys in key_manager.api_keys.items():
|
| 592 |
-
status[service] = {
|
| 593 |
-
'configured': len([k for k in keys if k]) > 0,
|
| 594 |
-
'count': len([k for k in keys if k])
|
| 595 |
-
}
|
| 596 |
-
metrics.track_request('key_status', time.time() - start_time)
|
| 597 |
-
return jsonify(status)
|
| 598 |
-
|
| 599 |
-
@app.route('/api/keys/health', methods=['GET'])
|
| 600 |
-
def key_health():
|
| 601 |
-
"""Get detailed health status for all keys"""
|
| 602 |
-
start_time = time.time()
|
| 603 |
-
|
| 604 |
-
# Get health data with safe serialization
|
| 605 |
-
health_data = {}
|
| 606 |
-
with key_manager.lock:
|
| 607 |
-
for key, health in key_manager.key_health.items():
|
| 608 |
-
# Mask the key for security (show only first 8 chars)
|
| 609 |
-
masked_key = key[:8] + '...' if len(key) > 8 else key
|
| 610 |
-
health_data[masked_key] = {
|
| 611 |
-
'status': health['status'],
|
| 612 |
-
'service': health['service'],
|
| 613 |
-
'last_success': health['last_success'].isoformat() if health['last_success'] else None,
|
| 614 |
-
'last_failure': health['last_failure'].isoformat() if health['last_failure'] else None,
|
| 615 |
-
'failure_count': health['failure_count'],
|
| 616 |
-
'consecutive_failures': health['consecutive_failures'],
|
| 617 |
-
'error_type': health['error_type'],
|
| 618 |
-
'last_error': health['last_error'][:100] if health['last_error'] else None # Truncate error message
|
| 619 |
-
}
|
| 620 |
-
|
| 621 |
-
metrics.track_request('key_health', time.time() - start_time)
|
| 622 |
-
return jsonify(health_data)
|
| 623 |
-
|
| 624 |
-
@app.route('/api/debug/keys', methods=['GET'])
|
| 625 |
-
def debug_keys():
|
| 626 |
-
"""Debug endpoint to check key loading"""
|
| 627 |
-
debug_info = {}
|
| 628 |
-
for service, keys in key_manager.api_keys.items():
|
| 629 |
-
debug_info[service] = {
|
| 630 |
-
'total_keys': len(keys),
|
| 631 |
-
'valid_keys': len([k for k in keys if k]),
|
| 632 |
-
'first_few_chars': [k[:8] + '...' if len(k) > 8 else k for k in keys[:3] if k] # Show first 3 for debugging
|
| 633 |
-
}
|
| 634 |
-
return jsonify(debug_info)
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
@app.route('/api/metrics', methods=['GET'])
|
| 638 |
-
def get_metrics():
|
| 639 |
-
"""Get proxy metrics"""
|
| 640 |
-
return jsonify(metrics.get_metrics())
|
| 641 |
-
|
| 642 |
-
@app.route('/', methods=['GET'])
|
| 643 |
-
def dashboard():
|
| 644 |
-
"""Dashboard webpage"""
|
| 645 |
-
metrics_data = metrics.get_metrics()
|
| 646 |
-
key_status_data = {}
|
| 647 |
-
key_health_data = {}
|
| 648 |
-
|
| 649 |
-
for service, keys in key_manager.api_keys.items():
|
| 650 |
-
key_status_data[service] = {
|
| 651 |
-
'configured': len([k for k in keys if k]) > 0,
|
| 652 |
-
'count': len([k for k in keys if k])
|
| 653 |
-
}
|
| 654 |
-
|
| 655 |
-
# Get health status for this service
|
| 656 |
-
service_health = {
|
| 657 |
-
'healthy': 0,
|
| 658 |
-
'rate_limited': 0,
|
| 659 |
-
'invalid_key': 0,
|
| 660 |
-
'quota_exceeded': 0,
|
| 661 |
-
'unknown': 0,
|
| 662 |
-
'failed': 0,
|
| 663 |
-
'key_details': [],
|
| 664 |
-
'prompt_tokens': 0,
|
| 665 |
-
'total_tokens': 0,
|
| 666 |
-
'avg_response_time': '0.00'
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
# Get token data for this service
|
| 670 |
-
if service in metrics_data['service_tokens']:
|
| 671 |
-
service_metrics = metrics_data['service_tokens'][service]
|
| 672 |
-
service_health['prompt_tokens'] = service_metrics['prompt_tokens']
|
| 673 |
-
service_health['total_tokens'] = service_metrics['total_tokens']
|
| 674 |
-
|
| 675 |
-
# Calculate average response time for this service
|
| 676 |
-
if service_metrics['response_times']:
|
| 677 |
-
avg_time = sum(service_metrics['response_times']) / len(service_metrics['response_times'])
|
| 678 |
-
service_health['avg_response_time'] = f"{avg_time:.2f}"
|
| 679 |
-
|
| 680 |
-
with key_manager.lock:
|
| 681 |
-
for key in keys:
|
| 682 |
-
if key and key in key_manager.key_health:
|
| 683 |
-
health = key_manager.key_health[key]
|
| 684 |
-
status = health['status']
|
| 685 |
-
|
| 686 |
-
# Count by status
|
| 687 |
-
if status in service_health:
|
| 688 |
-
service_health[status] += 1
|
| 689 |
-
else:
|
| 690 |
-
service_health['failed'] += 1
|
| 691 |
-
|
| 692 |
-
# Add key details (masked for security)
|
| 693 |
-
masked_key = key[:8] + '...' if len(key) > 8 else key
|
| 694 |
-
service_health['key_details'].append({
|
| 695 |
-
'key': masked_key,
|
| 696 |
-
'status': status,
|
| 697 |
-
'last_success': health['last_success'].strftime('%Y-%m-%d %H:%M:%S') if health['last_success'] else 'Never',
|
| 698 |
-
'last_failure': health['last_failure'].strftime('%Y-%m-%d %H:%M:%S') if health['last_failure'] else 'Never',
|
| 699 |
-
'consecutive_failures': health['consecutive_failures'],
|
| 700 |
-
'error_type': health['error_type']
|
| 701 |
-
})
|
| 702 |
-
else:
|
| 703 |
-
service_health['unknown'] += 1
|
| 704 |
-
|
| 705 |
-
key_health_data[service] = service_health
|
| 706 |
-
|
| 707 |
-
uptime_hours = metrics_data['uptime_seconds'] / 3600
|
| 708 |
-
uptime_display = f"{uptime_hours:.1f} hours" if uptime_hours >= 1 else f"{metrics_data['uptime_seconds']:.0f} seconds"
|
| 709 |
-
|
| 710 |
-
# Get current deployment URL
|
| 711 |
-
base_url = get_base_url()
|
| 712 |
-
|
| 713 |
-
# Get configurable dashboard variables from environment
|
| 714 |
-
dashboard_config = {
|
| 715 |
-
'title': f"{os.getenv('BRAND_EMOJI', 'π±')} {os.getenv('DASHBOARD_TITLE', 'NyanProxy Dashboard')}",
|
| 716 |
-
'subtitle': os.getenv('DASHBOARD_SUBTITLE', 'Purr-fect monitoring for your AI service proxy!'),
|
| 717 |
-
'refresh_interval': int(os.getenv('DASHBOARD_REFRESH_INTERVAL', 30)),
|
| 718 |
-
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 719 |
-
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 720 |
-
'brand_description': os.getenv('BRAND_DESCRIPTION', 'Meow-nificent AI Proxy!')
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
return render_template('dashboard.html',
|
| 724 |
-
base_url=base_url,
|
| 725 |
-
uptime_display=uptime_display,
|
| 726 |
-
total_requests=metrics_data['total_requests'],
|
| 727 |
-
total_prompts=metrics_data['request_counts']['chat_completions'],
|
| 728 |
-
total_tokens=metrics_data['total_tokens'],
|
| 729 |
-
average_response_time=f"{metrics_data['average_response_time']:.2f}s",
|
| 730 |
-
key_status_data=key_status_data,
|
| 731 |
-
key_health_data=key_health_data,
|
| 732 |
-
request_counts=metrics_data['request_counts'],
|
| 733 |
-
error_counts=metrics_data['error_counts'],
|
| 734 |
-
last_request=metrics_data['last_request'],
|
| 735 |
-
config=dashboard_config)
|
| 736 |
-
|
| 737 |
-
def get_base_url():
|
| 738 |
-
"""Get the base URL of the current deployment"""
|
| 739 |
-
# Try to detect HF Space URL
|
| 740 |
-
space_id = os.getenv('SPACE_ID')
|
| 741 |
-
if space_id:
|
| 742 |
-
# Convert username/space-name to username-space-name format
|
| 743 |
-
space_url = space_id.replace('/', '-')
|
| 744 |
-
return f'https://{space_url}.hf.space'
|
| 745 |
-
|
| 746 |
-
# Try other common deployment patterns
|
| 747 |
-
render_url = os.getenv('RENDER_EXTERNAL_URL')
|
| 748 |
-
if render_url:
|
| 749 |
-
return render_url
|
| 750 |
-
|
| 751 |
-
railway_url = os.getenv('RAILWAY_STATIC_URL')
|
| 752 |
-
if railway_url:
|
| 753 |
-
return railway_url
|
| 754 |
-
|
| 755 |
-
# Fallback to localhost for development
|
| 756 |
-
port = os.getenv('PORT', 7860)
|
| 757 |
-
return f'http://localhost:{port}'
|
| 758 |
-
|
| 759 |
-
# Add Anthropic endpoints
|
| 760 |
-
@app.route('/anthropic/v1/messages', methods=['POST'])
|
| 761 |
-
def anthropic_messages():
|
| 762 |
-
"""Proxy Anthropic messages with token tracking"""
|
| 763 |
-
start_time = time.time()
|
| 764 |
-
max_retries = 3
|
| 765 |
-
|
| 766 |
-
for attempt in range(max_retries):
|
| 767 |
-
api_key = key_manager.get_api_key('anthropic')
|
| 768 |
-
if not api_key:
|
| 769 |
-
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 770 |
-
return jsonify({"error": "No Anthropic API key configured"}), 500
|
| 771 |
-
|
| 772 |
-
headers = {
|
| 773 |
-
'x-api-key': api_key,
|
| 774 |
-
'Content-Type': 'application/json',
|
| 775 |
-
'anthropic-version': '2023-06-01'
|
| 776 |
-
}
|
| 777 |
-
|
| 778 |
-
try:
|
| 779 |
-
response = requests.post(
|
| 780 |
-
'https://api.anthropic.com/v1/messages',
|
| 781 |
-
headers=headers,
|
| 782 |
-
json=request.json,
|
| 783 |
-
stream=request.json.get('stream', False)
|
| 784 |
-
)
|
| 785 |
-
|
| 786 |
-
response_time = time.time() - start_time
|
| 787 |
-
|
| 788 |
-
# Handle different response codes
|
| 789 |
-
if response.status_code >= 400:
|
| 790 |
-
error_text = response.text
|
| 791 |
-
if key_manager.handle_api_error('anthropic', api_key, error_text, response.status_code):
|
| 792 |
-
if attempt < max_retries - 1:
|
| 793 |
-
time.sleep(1) # Brief pause before retry
|
| 794 |
-
continue
|
| 795 |
-
else:
|
| 796 |
-
# Success - update key health
|
| 797 |
-
key_manager.update_key_health(api_key, True)
|
| 798 |
-
|
| 799 |
-
# Extract token information from response
|
| 800 |
-
tokens = None
|
| 801 |
-
response_content = response.content
|
| 802 |
-
|
| 803 |
-
request_json = request.get_json() if request else {}
|
| 804 |
-
is_streaming = request_json.get('stream', False) if request_json else False
|
| 805 |
-
|
| 806 |
-
if response.status_code == 200:
|
| 807 |
-
if is_streaming:
|
| 808 |
-
# For streaming requests, count prompt tokens only
|
| 809 |
-
try:
|
| 810 |
-
token_result = unified_tokenizer.count_tokens(
|
| 811 |
-
request_data=request_json,
|
| 812 |
-
service="anthropic"
|
| 813 |
-
)
|
| 814 |
-
tokens = {
|
| 815 |
-
'prompt_tokens': token_result['prompt_tokens'],
|
| 816 |
-
'completion_tokens': 0, # Can't count completion tokens for streaming
|
| 817 |
-
'total_tokens': token_result['prompt_tokens']
|
| 818 |
-
}
|
| 819 |
-
except Exception:
|
| 820 |
-
pass
|
| 821 |
-
else:
|
| 822 |
-
# Non-streaming request - try to get full token counts
|
| 823 |
-
try:
|
| 824 |
-
response_data = json.loads(response_content)
|
| 825 |
-
if 'usage' in response_data:
|
| 826 |
-
usage = response_data['usage']
|
| 827 |
-
tokens = {
|
| 828 |
-
'prompt_tokens': usage.get('input_tokens', 0),
|
| 829 |
-
'completion_tokens': usage.get('output_tokens', 0),
|
| 830 |
-
'total_tokens': usage.get('input_tokens', 0) + usage.get('output_tokens', 0)
|
| 831 |
-
}
|
| 832 |
-
else:
|
| 833 |
-
# Fallback: estimate tokens using advanced tokenizer
|
| 834 |
-
response_text = ""
|
| 835 |
-
if 'content' in response_data:
|
| 836 |
-
for content in response_data['content']:
|
| 837 |
-
if content.get('type') == 'text':
|
| 838 |
-
response_text += content.get('text', '') + " "
|
| 839 |
-
|
| 840 |
-
token_result = unified_tokenizer.count_tokens(
|
| 841 |
-
request_data=request_json,
|
| 842 |
-
service="anthropic",
|
| 843 |
-
response_text=response_text.strip()
|
| 844 |
-
)
|
| 845 |
-
tokens = {
|
| 846 |
-
'prompt_tokens': token_result['prompt_tokens'],
|
| 847 |
-
'completion_tokens': token_result['completion_tokens'],
|
| 848 |
-
'total_tokens': token_result['total_tokens']
|
| 849 |
-
}
|
| 850 |
-
except (json.JSONDecodeError, Exception):
|
| 851 |
-
# Still try to count tokens from request at least
|
| 852 |
-
try:
|
| 853 |
-
token_result = unified_tokenizer.count_tokens(
|
| 854 |
-
request_data=request_json,
|
| 855 |
-
service="anthropic"
|
| 856 |
-
)
|
| 857 |
-
tokens = {
|
| 858 |
-
'prompt_tokens': token_result['prompt_tokens'],
|
| 859 |
-
'completion_tokens': 0,
|
| 860 |
-
'total_tokens': token_result['prompt_tokens']
|
| 861 |
-
}
|
| 862 |
-
except Exception:
|
| 863 |
-
pass
|
| 864 |
-
|
| 865 |
-
metrics.track_request('chat_completions', response_time, error=response.status_code >= 400, tokens=tokens, service='anthropic')
|
| 866 |
-
|
| 867 |
-
if is_streaming:
|
| 868 |
-
return Response(
|
| 869 |
-
response.iter_content(chunk_size=1024),
|
| 870 |
-
content_type=response.headers.get('content-type'),
|
| 871 |
-
status=response.status_code
|
| 872 |
-
)
|
| 873 |
-
else:
|
| 874 |
-
return Response(
|
| 875 |
-
response_content,
|
| 876 |
-
content_type=response.headers.get('content-type'),
|
| 877 |
-
status=response.status_code
|
| 878 |
-
)
|
| 879 |
-
|
| 880 |
-
except Exception as e:
|
| 881 |
-
error_msg = str(e)
|
| 882 |
-
if key_manager.handle_api_error('anthropic', api_key, error_msg):
|
| 883 |
-
if attempt < max_retries - 1:
|
| 884 |
-
time.sleep(1)
|
| 885 |
-
continue
|
| 886 |
-
|
| 887 |
-
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 888 |
-
return jsonify({"error": error_msg}), 500
|
| 889 |
-
|
| 890 |
-
# If we get here, all retries failed
|
| 891 |
-
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 892 |
-
return jsonify({"error": "All API keys rate limited or failed"}), 429
|
| 893 |
-
|
| 894 |
-
@app.route('/anthropic/v1/models', methods=['GET'])
|
| 895 |
-
def anthropic_models():
|
| 896 |
-
"""Anthropic models endpoint (placeholder)"""
|
| 897 |
-
return jsonify({"error": "Anthropic models endpoint not implemented yet"}), 501
|
| 898 |
|
| 899 |
if __name__ == '__main__':
|
| 900 |
port = int(os.getenv('PORT', 7860))
|
| 901 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Main application entry point for Hugging Face compatibility
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
# Add the project root to Python path so imports work correctly
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
# Import the Flask app from the core module
|
| 12 |
+
from core.app import app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
if __name__ == '__main__':
|
| 15 |
port = int(os.getenv('PORT', 7860))
|
| 16 |
+
debug_mode = os.getenv('DEBUG', 'False').lower() == 'true'
|
| 17 |
+
|
| 18 |
+
if debug_mode:
|
| 19 |
+
# Development mode with debugging
|
| 20 |
+
app.run(host='0.0.0.0', port=port, debug=True, threaded=True)
|
| 21 |
+
else:
|
| 22 |
+
# Production mode with threading and better performance
|
| 23 |
+
app.run(
|
| 24 |
+
host='0.0.0.0',
|
| 25 |
+
port=port,
|
| 26 |
+
debug=False,
|
| 27 |
+
threaded=True,
|
| 28 |
+
processes=1,
|
| 29 |
+
use_reloader=False
|
| 30 |
+
)
|
config/.env.example
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# === AUTHENTICATION CONFIGURATION ===
|
| 2 |
+
# Choose authentication mode: "none", "proxy_key", or "user_token"
|
| 3 |
+
AUTH_MODE=none
|
| 4 |
+
|
| 5 |
+
# For proxy_key mode: single shared password
|
| 6 |
+
PROXY_PASSWORD=proxy_pass
|
| 7 |
+
|
| 8 |
+
# Admin key for accessing admin endpoints
|
| 9 |
+
ADMIN_KEY=your_admin_key_here
|
| 10 |
+
|
| 11 |
+
# === USER TOKEN CONFIGURATION ===
|
| 12 |
+
# Maximum IP addresses per user (default: 3)
|
| 13 |
+
MAX_IPS_PER_USER=3
|
| 14 |
+
|
| 15 |
+
# Auto-ban users who exceed IP limit (default: true)
|
| 16 |
+
MAX_IPS_AUTO_BAN=true
|
| 17 |
+
|
| 18 |
+
# === RATE LIMITING ===
|
| 19 |
+
# Enable rate limiting (default: true)
|
| 20 |
+
RATE_LIMIT_ENABLED=true
|
| 21 |
+
|
| 22 |
+
# Requests per minute per user/IP (default: 60)
|
| 23 |
+
RATE_LIMIT_PER_MINUTE=60
|
| 24 |
+
|
| 25 |
+
# === FIREBASE CONFIGURATION ===
|
| 26 |
+
# Firebase Realtime Database URL
|
| 27 |
+
FIREBASE_URL=https://your-project-default-rtdb.firebaseio.com/
|
| 28 |
+
|
| 29 |
+
# Firebase service account key (JSON string)
|
| 30 |
+
# You can also use Application Default Credentials instead
|
| 31 |
+
FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"your-project",...}
|
| 32 |
+
|
| 33 |
+
# === API KEYS ===
|
| 34 |
+
# OpenAI API Keys (multiple keys separated by commas or newlines)
|
| 35 |
+
OPENAI_API_KEYS=sk-key1,sk-key2,sk-key3
|
| 36 |
+
|
| 37 |
+
# Anthropic API Keys
|
| 38 |
+
ANTHROPIC_API_KEYS=sk-ant-key1,sk-ant-key2
|
| 39 |
+
|
| 40 |
+
# === DASHBOARD CONFIGURATION ===
|
| 41 |
+
BRAND_NAME=NyanProxy
|
| 42 |
+
BRAND_EMOJI=π±
|
| 43 |
+
DASHBOARD_TITLE=NyanProxy Dashboard
|
| 44 |
+
DASHBOARD_SUBTITLE=Purr-fect monitoring for your AI service proxy!
|
| 45 |
+
DASHBOARD_REFRESH_INTERVAL=30
|
| 46 |
+
|
| 47 |
+
# === DEPLOYMENT ===
|
| 48 |
+
PORT=7860
|
| 49 |
+
|
| 50 |
+
# Optional: Override automatic URL detection
|
| 51 |
+
# APP_URL=https://your-custom-domain.com
|
config/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==2.3.3
|
| 2 |
+
requests==2.31.0
|
| 3 |
+
gunicorn==21.2.0
|
| 4 |
+
python-dotenv==1.0.0
|
| 5 |
+
tiktoken==0.5.2
|
| 6 |
+
firebase-admin==6.3.0
|
| 7 |
+
Flask-WTF==1.1.1
|
core/app.py
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, request, jsonify, Response, render_template, g, session
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
import time
|
| 7 |
+
import threading
|
| 8 |
+
from typing import Dict, List
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from .health_checker import health_manager, HealthResult
|
| 11 |
+
|
| 12 |
+
# Import tokenizer libraries
|
| 13 |
+
try:
|
| 14 |
+
import tiktoken
|
| 15 |
+
TIKTOKEN_AVAILABLE = True
|
| 16 |
+
except ImportError:
|
| 17 |
+
TIKTOKEN_AVAILABLE = False
|
| 18 |
+
print("Warning: tiktoken not available, token counting will be approximate")
|
| 19 |
+
|
| 20 |
+
# Load environment variables from .env file
|
| 21 |
+
try:
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
load_dotenv()
|
| 24 |
+
except ImportError:
|
| 25 |
+
# If python-dotenv is not installed, continue without it
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
from ai.tokenizers.unified_tokenizer import unified_tokenizer
|
| 29 |
+
|
| 30 |
+
# Import authentication system
|
| 31 |
+
from src.config.auth import auth_config
|
| 32 |
+
from src.middleware.auth import require_auth, check_quota, track_token_usage
|
| 33 |
+
from src.services.event_logger import event_logger
|
| 34 |
+
from src.services.structured_event_logger import structured_logger
|
| 35 |
+
from src.services.user_store import user_store
|
| 36 |
+
from src.routes.admin import admin_bp
|
| 37 |
+
from src.routes.admin_web import admin_web_bp
|
| 38 |
+
from src.routes.model_families_admin import model_families_bp
|
| 39 |
+
from src.middleware.auth import generate_csrf_token
|
| 40 |
+
from src.services.model_families import model_manager, AIProvider
|
| 41 |
+
|
| 42 |
+
class MetricsTracker:
|
| 43 |
+
def __init__(self):
|
| 44 |
+
self.start_time = time.time()
|
| 45 |
+
self.request_counts = {
|
| 46 |
+
'chat_completions': 0,
|
| 47 |
+
'models': 0,
|
| 48 |
+
'health': 0,
|
| 49 |
+
'key_status': 0
|
| 50 |
+
}
|
| 51 |
+
self.error_counts = {
|
| 52 |
+
'chat_completions': 0,
|
| 53 |
+
'models': 0,
|
| 54 |
+
'key_errors': 0
|
| 55 |
+
}
|
| 56 |
+
self.total_requests = 0
|
| 57 |
+
self.last_request_time = None
|
| 58 |
+
self.response_times = []
|
| 59 |
+
# Token tracking
|
| 60 |
+
self.total_tokens = 0
|
| 61 |
+
self.prompt_tokens = 0
|
| 62 |
+
self.completion_tokens = 0
|
| 63 |
+
# Per-service tracking (dynamically populated as services are used)
|
| 64 |
+
self.service_tokens = {}
|
| 65 |
+
self.lock = threading.Lock()
|
| 66 |
+
|
| 67 |
+
def track_request(self, endpoint: str, response_time: float = None, error: bool = False, tokens: dict = None, service: str = None):
|
| 68 |
+
with self.lock:
|
| 69 |
+
self.total_requests += 1
|
| 70 |
+
self.last_request_time = datetime.now()
|
| 71 |
+
|
| 72 |
+
if endpoint in self.request_counts:
|
| 73 |
+
self.request_counts[endpoint] += 1
|
| 74 |
+
|
| 75 |
+
if error and endpoint in self.error_counts:
|
| 76 |
+
self.error_counts[endpoint] += 1
|
| 77 |
+
|
| 78 |
+
if response_time is not None:
|
| 79 |
+
self.response_times.append(response_time)
|
| 80 |
+
# Keep only last 100 response times
|
| 81 |
+
if len(self.response_times) > 100:
|
| 82 |
+
self.response_times.pop(0)
|
| 83 |
+
|
| 84 |
+
# Always track requests, even if tokens is None
|
| 85 |
+
if service:
|
| 86 |
+
# Initialize service if not exists
|
| 87 |
+
if service not in self.service_tokens:
|
| 88 |
+
self.service_tokens[service] = {
|
| 89 |
+
'successful_requests': 0,
|
| 90 |
+
'total_tokens': 0,
|
| 91 |
+
'response_times': []
|
| 92 |
+
}
|
| 93 |
+
print(f"DEBUG: Initialized tracking for new service: {service}")
|
| 94 |
+
|
| 95 |
+
# Track successful requests (only count if not an error)
|
| 96 |
+
if not error:
|
| 97 |
+
self.service_tokens[service]['successful_requests'] += 1
|
| 98 |
+
print(f"DEBUG: Incremented successful_requests for {service} to {self.service_tokens[service]['successful_requests']}")
|
| 99 |
+
|
| 100 |
+
# Track response times per service
|
| 101 |
+
if response_time is not None:
|
| 102 |
+
self.service_tokens[service]['response_times'].append(response_time)
|
| 103 |
+
# Keep only last 100 response times per service
|
| 104 |
+
if len(self.service_tokens[service]['response_times']) > 100:
|
| 105 |
+
self.service_tokens[service]['response_times'].pop(0)
|
| 106 |
+
|
| 107 |
+
# Track tokens if available
|
| 108 |
+
if tokens and service:
|
| 109 |
+
prompt_tokens = tokens.get('prompt_tokens', 0)
|
| 110 |
+
completion_tokens = tokens.get('completion_tokens', 0)
|
| 111 |
+
total_tokens = tokens.get('total_tokens', 0)
|
| 112 |
+
|
| 113 |
+
print(f"DEBUG: MetricsTracker - Service: {service}, Tokens: {tokens}, Error: {error}")
|
| 114 |
+
|
| 115 |
+
# Update global counters
|
| 116 |
+
self.prompt_tokens += prompt_tokens
|
| 117 |
+
self.completion_tokens += completion_tokens
|
| 118 |
+
self.total_tokens += total_tokens
|
| 119 |
+
|
| 120 |
+
# Track total tokens (input + output)
|
| 121 |
+
self.service_tokens[service]['total_tokens'] += total_tokens
|
| 122 |
+
print(f"DEBUG: Added {total_tokens} tokens to {service}, total now: {self.service_tokens[service]['total_tokens']}")
|
| 123 |
+
else:
|
| 124 |
+
print(f"DEBUG: NOT tracking tokens - tokens={tokens}, service={service}")
|
| 125 |
+
|
| 126 |
+
def get_uptime(self):
|
| 127 |
+
return time.time() - self.start_time
|
| 128 |
+
|
| 129 |
+
def get_average_response_time(self):
|
| 130 |
+
if not self.response_times:
|
| 131 |
+
return 0
|
| 132 |
+
return sum(self.response_times) / len(self.response_times)
|
| 133 |
+
|
| 134 |
+
def get_metrics(self):
|
| 135 |
+
with self.lock:
|
| 136 |
+
return {
|
| 137 |
+
'uptime_seconds': self.get_uptime(),
|
| 138 |
+
'total_requests': self.total_requests,
|
| 139 |
+
'request_counts': self.request_counts.copy(),
|
| 140 |
+
'error_counts': self.error_counts.copy(),
|
| 141 |
+
'last_request': self.last_request_time.isoformat() if self.last_request_time else None,
|
| 142 |
+
'average_response_time': self.get_average_response_time(),
|
| 143 |
+
'total_tokens': self.total_tokens,
|
| 144 |
+
'prompt_tokens': self.prompt_tokens,
|
| 145 |
+
'completion_tokens': self.completion_tokens,
|
| 146 |
+
'service_tokens': self.service_tokens.copy()
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class APIKeyManager:
|
| 152 |
+
def __init__(self):
|
| 153 |
+
self.api_keys = self._load_api_keys()
|
| 154 |
+
self.key_usage = {} # Track usage per key for rate limiting
|
| 155 |
+
self.failed_keys = set() # Track temporarily failed keys
|
| 156 |
+
self.key_health = {} # Track detailed health status for each key
|
| 157 |
+
self.lock = threading.Lock() # Thread safety for key health updates
|
| 158 |
+
self._initialize_key_health()
|
| 159 |
+
|
| 160 |
+
def _parse_keys(self, key_string: str) -> List[str]:
|
| 161 |
+
"""Parse comma and line-separated keys"""
|
| 162 |
+
if not key_string:
|
| 163 |
+
return []
|
| 164 |
+
|
| 165 |
+
keys = []
|
| 166 |
+
# Split by both commas and newlines
|
| 167 |
+
for line in key_string.replace(',', '\n').split('\n'):
|
| 168 |
+
key = line.strip()
|
| 169 |
+
if key and key not in keys:
|
| 170 |
+
keys.append(key)
|
| 171 |
+
return keys
|
| 172 |
+
|
| 173 |
+
def _load_api_keys(self) -> Dict[str, List[str]]:
|
| 174 |
+
"""Load API keys from environment variables or config"""
|
| 175 |
+
keys = {}
|
| 176 |
+
|
| 177 |
+
# OpenAI keys - support comma/line separated format
|
| 178 |
+
openai_keys = []
|
| 179 |
+
|
| 180 |
+
# Try comma/line separated format first
|
| 181 |
+
bulk_key = os.getenv('OPENAI_API_KEYS')
|
| 182 |
+
if bulk_key:
|
| 183 |
+
parsed_keys = self._parse_keys(bulk_key)
|
| 184 |
+
print(f"DEBUG: Found {len(parsed_keys)} OpenAI keys from OPENAI_API_KEYS")
|
| 185 |
+
openai_keys.extend(parsed_keys)
|
| 186 |
+
|
| 187 |
+
# Fallback to single key
|
| 188 |
+
if not openai_keys:
|
| 189 |
+
single_key = os.getenv('OPENAI_API_KEY')
|
| 190 |
+
if single_key:
|
| 191 |
+
openai_keys.extend(self._parse_keys(single_key))
|
| 192 |
+
|
| 193 |
+
keys['openai'] = list(set(openai_keys)) # Remove duplicates
|
| 194 |
+
|
| 195 |
+
# Anthropic keys
|
| 196 |
+
anthropic_keys = []
|
| 197 |
+
bulk_key = os.getenv('ANTHROPIC_API_KEYS')
|
| 198 |
+
if bulk_key:
|
| 199 |
+
anthropic_keys.extend(self._parse_keys(bulk_key))
|
| 200 |
+
|
| 201 |
+
single_key = os.getenv('ANTHROPIC_API_KEY')
|
| 202 |
+
if single_key:
|
| 203 |
+
anthropic_keys.extend(self._parse_keys(single_key))
|
| 204 |
+
|
| 205 |
+
keys['anthropic'] = list(set(anthropic_keys))
|
| 206 |
+
|
| 207 |
+
return keys
|
| 208 |
+
|
| 209 |
+
def _initialize_key_health(self):
|
| 210 |
+
"""Initialize health tracking for all keys"""
|
| 211 |
+
with self.lock:
|
| 212 |
+
for service, keys in self.api_keys.items():
|
| 213 |
+
for key in keys:
|
| 214 |
+
if key and key not in self.key_health:
|
| 215 |
+
self.key_health[key] = {
|
| 216 |
+
'status': 'unknown',
|
| 217 |
+
'service': service,
|
| 218 |
+
'last_error': None,
|
| 219 |
+
'last_success': None,
|
| 220 |
+
'last_failure': None,
|
| 221 |
+
'failure_count': 0,
|
| 222 |
+
'consecutive_failures': 0,
|
| 223 |
+
'error_type': None
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
def perform_proactive_health_check(self, service: str, api_key: str):
|
| 227 |
+
"""Perform proactive health check using the health checker system"""
|
| 228 |
+
try:
|
| 229 |
+
result = health_manager.check_service_health(service, api_key)
|
| 230 |
+
|
| 231 |
+
# Update key health based on result
|
| 232 |
+
if result.status == 'healthy':
|
| 233 |
+
self.update_key_health(api_key, True)
|
| 234 |
+
else:
|
| 235 |
+
self.update_key_health(
|
| 236 |
+
api_key,
|
| 237 |
+
False,
|
| 238 |
+
result.status_code,
|
| 239 |
+
result.error_message
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
except Exception as e:
|
| 243 |
+
# If health check fails, mark as network error
|
| 244 |
+
self.update_key_health(api_key, False, None, f"Health check failed: {str(e)}")
|
| 245 |
+
|
| 246 |
+
def run_initial_health_checks(self):
|
| 247 |
+
"""Run initial health checks for all keys in background"""
|
| 248 |
+
def check_all_keys():
|
| 249 |
+
print("Starting initial health checks for all API keys...")
|
| 250 |
+
for service, keys in self.api_keys.items():
|
| 251 |
+
for key in keys:
|
| 252 |
+
if key:
|
| 253 |
+
print(f"Checking {service} key: {key[:8]}...")
|
| 254 |
+
self.perform_proactive_health_check(service, key)
|
| 255 |
+
time.sleep(0.5) # Small delay to avoid overwhelming APIs
|
| 256 |
+
print("Initial health checks completed.")
|
| 257 |
+
|
| 258 |
+
# Run in background thread
|
| 259 |
+
thread = threading.Thread(target=check_all_keys, daemon=True)
|
| 260 |
+
thread.start()
|
| 261 |
+
|
| 262 |
+
def _classify_error(self, status_code: int, error_message: str) -> tuple:
|
| 263 |
+
"""Classify error type and return (error_type, is_retryable)"""
|
| 264 |
+
error_msg_lower = error_message.lower()
|
| 265 |
+
|
| 266 |
+
# Authentication errors (not retryable with same key)
|
| 267 |
+
if status_code in [401, 403]:
|
| 268 |
+
return 'invalid_key', False
|
| 269 |
+
|
| 270 |
+
# Rate limiting (retryable)
|
| 271 |
+
if status_code == 429:
|
| 272 |
+
return 'rate_limited', True
|
| 273 |
+
|
| 274 |
+
# Quota/billing issues
|
| 275 |
+
quota_indicators = ['quota exceeded', 'billing', 'insufficient_quota', 'usage limit']
|
| 276 |
+
if any(indicator in error_msg_lower for indicator in quota_indicators):
|
| 277 |
+
return 'quota_exceeded', False
|
| 278 |
+
|
| 279 |
+
# Rate limit indicators in message
|
| 280 |
+
rate_limit_indicators = [
|
| 281 |
+
'rate limit', 'too many requests', 'rate_limit_exceeded',
|
| 282 |
+
'requests per minute', 'rpm', 'tpm'
|
| 283 |
+
]
|
| 284 |
+
if any(indicator in error_msg_lower for indicator in rate_limit_indicators):
|
| 285 |
+
return 'rate_limited', True
|
| 286 |
+
|
| 287 |
+
# Server errors (potentially retryable)
|
| 288 |
+
if status_code >= 500:
|
| 289 |
+
return 'server_error', True
|
| 290 |
+
|
| 291 |
+
# Other client errors
|
| 292 |
+
if status_code >= 400:
|
| 293 |
+
return 'client_error', False
|
| 294 |
+
|
| 295 |
+
return 'unknown_error', False
|
| 296 |
+
|
| 297 |
+
def get_api_key(self, service: str, exclude_failed: bool = True) -> str:
|
| 298 |
+
"""Get next available API key for the service with rate limit handling"""
|
| 299 |
+
if service not in self.api_keys or not self.api_keys[service]:
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
available_keys = [key for key in self.api_keys[service] if key]
|
| 303 |
+
|
| 304 |
+
# Remove failed keys if requested
|
| 305 |
+
if exclude_failed:
|
| 306 |
+
available_keys = [key for key in available_keys if key not in self.failed_keys]
|
| 307 |
+
|
| 308 |
+
if not available_keys:
|
| 309 |
+
# If all keys failed, try again with failed keys (maybe they recovered)
|
| 310 |
+
if exclude_failed:
|
| 311 |
+
return self.get_api_key(service, exclude_failed=False)
|
| 312 |
+
return None
|
| 313 |
+
|
| 314 |
+
# Simple round-robin selection
|
| 315 |
+
if service not in self.key_usage:
|
| 316 |
+
self.key_usage[service] = 0
|
| 317 |
+
|
| 318 |
+
key_index = self.key_usage[service] % len(available_keys)
|
| 319 |
+
self.key_usage[service] += 1
|
| 320 |
+
|
| 321 |
+
return available_keys[key_index]
|
| 322 |
+
|
| 323 |
+
def update_key_health(self, key: str, success: bool, status_code: int = None, error_message: str = None):
|
| 324 |
+
"""Update key health status based on API response"""
|
| 325 |
+
with self.lock:
|
| 326 |
+
if key not in self.key_health:
|
| 327 |
+
# Initialize if not exists
|
| 328 |
+
service = next((svc for svc, keys in self.api_keys.items() if key in keys), 'unknown')
|
| 329 |
+
self.key_health[key] = {
|
| 330 |
+
'status': 'unknown',
|
| 331 |
+
'service': service,
|
| 332 |
+
'last_error': None,
|
| 333 |
+
'last_success': None,
|
| 334 |
+
'last_failure': None,
|
| 335 |
+
'failure_count': 0,
|
| 336 |
+
'consecutive_failures': 0,
|
| 337 |
+
'error_type': None
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
health = self.key_health[key]
|
| 341 |
+
now = datetime.now()
|
| 342 |
+
|
| 343 |
+
if success:
|
| 344 |
+
health['status'] = 'healthy'
|
| 345 |
+
health['last_success'] = now
|
| 346 |
+
health['consecutive_failures'] = 0
|
| 347 |
+
health['error_type'] = None
|
| 348 |
+
health['last_error'] = None
|
| 349 |
+
else:
|
| 350 |
+
health['last_failure'] = now
|
| 351 |
+
health['failure_count'] += 1
|
| 352 |
+
health['consecutive_failures'] += 1
|
| 353 |
+
|
| 354 |
+
if status_code and error_message:
|
| 355 |
+
error_type, is_retryable = self._classify_error(status_code, error_message)
|
| 356 |
+
health['error_type'] = error_type
|
| 357 |
+
health['status'] = error_type
|
| 358 |
+
health['last_error'] = error_message
|
| 359 |
+
|
| 360 |
+
# Only mark as failed for retryable errors
|
| 361 |
+
if is_retryable:
|
| 362 |
+
self.failed_keys.add(key)
|
| 363 |
+
# Auto-recovery after some time
|
| 364 |
+
threading.Timer(300, lambda: self.failed_keys.discard(key)).start()
|
| 365 |
+
else:
|
| 366 |
+
health['status'] = 'failed'
|
| 367 |
+
health['error_type'] = 'unknown_error'
|
| 368 |
+
|
| 369 |
+
def mark_key_failed(self, service: str, key: str):
|
| 370 |
+
"""Mark a key as temporarily failed (legacy method)"""
|
| 371 |
+
self.failed_keys.add(key)
|
| 372 |
+
# Auto-recovery after some time (simplified)
|
| 373 |
+
threading.Timer(300, lambda: self.failed_keys.discard(key)).start()
|
| 374 |
+
|
| 375 |
+
def handle_api_error(self, service: str, key: str, error_message: str, status_code: int = None) -> bool:
|
| 376 |
+
"""Handle API error and return True if should retry with different key"""
|
| 377 |
+
# Update key health with failure
|
| 378 |
+
self.update_key_health(key, False, status_code, error_message)
|
| 379 |
+
|
| 380 |
+
# Check if error is retryable
|
| 381 |
+
if status_code and error_message:
|
| 382 |
+
error_type, is_retryable = self._classify_error(status_code, error_message)
|
| 383 |
+
return is_retryable
|
| 384 |
+
|
| 385 |
+
# Legacy fallback for rate limiting
|
| 386 |
+
rate_limit_indicators = [
|
| 387 |
+
'rate limit', 'too many requests', 'quota exceeded',
|
| 388 |
+
'rate_limit_exceeded', 'requests per minute', 'rpm', 'tpm'
|
| 389 |
+
]
|
| 390 |
+
if any(indicator in error_message.lower() for indicator in rate_limit_indicators):
|
| 391 |
+
self.mark_key_failed(service, key)
|
| 392 |
+
return True
|
| 393 |
+
|
| 394 |
+
return False
|
| 395 |
+
|
| 396 |
+
key_manager = APIKeyManager()
|
| 397 |
+
metrics = MetricsTracker()
|
| 398 |
+
app = Flask(__name__, template_folder='../pages', static_folder='../static')
|
| 399 |
+
|
| 400 |
+
# Configure Flask
|
| 401 |
+
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'default-secret-key-change-in-production')
|
| 402 |
+
app.permanent_session_lifetime = timedelta(hours=24) # Sessions last 24 hours
|
| 403 |
+
|
| 404 |
+
# Register admin blueprints
|
| 405 |
+
app.register_blueprint(admin_bp)
|
| 406 |
+
app.register_blueprint(admin_web_bp)
|
| 407 |
+
app.register_blueprint(model_families_bp)
|
| 408 |
+
|
| 409 |
+
# Add CSRF token to template context
|
| 410 |
+
@app.context_processor
|
| 411 |
+
def inject_csrf_token():
|
| 412 |
+
return dict(csrf_token=generate_csrf_token)
|
| 413 |
+
|
| 414 |
+
# Run initial health checks in background
|
| 415 |
+
key_manager.run_initial_health_checks()
|
| 416 |
+
|
| 417 |
+
# Add request logging middleware
|
| 418 |
+
@app.before_request
|
| 419 |
+
def log_request():
|
| 420 |
+
print(f"DEBUG: Request to {request.method} {request.path} from {request.remote_addr}")
|
| 421 |
+
print(f"DEBUG: Headers: {dict(request.headers)}")
|
| 422 |
+
if request.method == 'POST':
|
| 423 |
+
print(f"DEBUG: Content-Type: {request.content_type}")
|
| 424 |
+
if request.is_json:
|
| 425 |
+
print(f"DEBUG: JSON Body keys: {list(request.json.keys()) if request.json else 'None'}")
|
| 426 |
+
print(f"DEBUG: Full URL: {request.url}")
|
| 427 |
+
|
| 428 |
+
@app.route('/health', methods=['GET'])
|
| 429 |
+
def health_check():
|
| 430 |
+
start_time = time.time()
|
| 431 |
+
result = jsonify({"status": "healthy", "service": "AI Proxy"})
|
| 432 |
+
metrics.track_request('health', time.time() - start_time)
|
| 433 |
+
return result
|
| 434 |
+
|
| 435 |
+
@app.route('/openai/v1/chat/completions', methods=['POST'])
|
| 436 |
+
@app.route('/v1/chat/completions', methods=['POST']) # Legacy support
|
| 437 |
+
@require_auth
|
| 438 |
+
def openai_chat_completions():
|
| 439 |
+
"""Proxy OpenAI chat completions with rate limit handling"""
|
| 440 |
+
start_time = time.time()
|
| 441 |
+
max_retries = 3
|
| 442 |
+
|
| 443 |
+
# Check quota for OpenAI models
|
| 444 |
+
has_quota, quota_error = check_quota('openai')
|
| 445 |
+
if not has_quota:
|
| 446 |
+
return jsonify({"error": quota_error}), 429
|
| 447 |
+
|
| 448 |
+
# Validate model is whitelisted
|
| 449 |
+
request_json = request.get_json() if request else {}
|
| 450 |
+
model = request_json.get('model', 'gpt-3.5-turbo')
|
| 451 |
+
if not model_manager.is_model_whitelisted(AIProvider.OPENAI, model):
|
| 452 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 453 |
+
return jsonify({"error": {"message": f"Model '{model}' is not whitelisted for use", "type": "model_not_allowed"}}), 403
|
| 454 |
+
|
| 455 |
+
for attempt in range(max_retries):
|
| 456 |
+
api_key = key_manager.get_api_key('openai')
|
| 457 |
+
if not api_key:
|
| 458 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 459 |
+
return jsonify({"error": "No OpenAI API key configured"}), 500
|
| 460 |
+
|
| 461 |
+
headers = {
|
| 462 |
+
'Authorization': f'Bearer {api_key}',
|
| 463 |
+
'Content-Type': 'application/json'
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
response = requests.post(
|
| 468 |
+
'https://api.openai.com/v1/chat/completions',
|
| 469 |
+
headers=headers,
|
| 470 |
+
json=request.json,
|
| 471 |
+
stream=request.json.get('stream', False)
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
response_time = time.time() - start_time
|
| 475 |
+
|
| 476 |
+
# Handle different response codes
|
| 477 |
+
if response.status_code >= 400:
|
| 478 |
+
error_text = response.text
|
| 479 |
+
if key_manager.handle_api_error('openai', api_key, error_text, response.status_code):
|
| 480 |
+
if attempt < max_retries - 1:
|
| 481 |
+
time.sleep(1) # Brief pause before retry
|
| 482 |
+
continue
|
| 483 |
+
else:
|
| 484 |
+
# Success - update key health
|
| 485 |
+
key_manager.update_key_health(api_key, True)
|
| 486 |
+
|
| 487 |
+
# Extract token information from response
|
| 488 |
+
tokens = None
|
| 489 |
+
response_content = response.content
|
| 490 |
+
request_json = request.get_json() if request else {}
|
| 491 |
+
is_streaming = request_json.get('stream', False) if request_json else False
|
| 492 |
+
|
| 493 |
+
if response.status_code == 200:
|
| 494 |
+
model = request_json.get('model', 'gpt-3.5-turbo')
|
| 495 |
+
|
| 496 |
+
# For streaming requests, count prompt tokens only
|
| 497 |
+
if is_streaming:
|
| 498 |
+
try:
|
| 499 |
+
token_result = unified_tokenizer.count_tokens(
|
| 500 |
+
request_data=request_json,
|
| 501 |
+
service="openai",
|
| 502 |
+
model=model
|
| 503 |
+
)
|
| 504 |
+
tokens = {
|
| 505 |
+
'prompt_tokens': token_result['prompt_tokens'],
|
| 506 |
+
'completion_tokens': 0, # Can't count completion tokens for streaming
|
| 507 |
+
'total_tokens': token_result['prompt_tokens']
|
| 508 |
+
}
|
| 509 |
+
except Exception:
|
| 510 |
+
pass
|
| 511 |
+
else:
|
| 512 |
+
# Non-streaming request - try to get full token counts
|
| 513 |
+
try:
|
| 514 |
+
response_data = json.loads(response_content)
|
| 515 |
+
if 'usage' in response_data:
|
| 516 |
+
tokens = response_data['usage']
|
| 517 |
+
else:
|
| 518 |
+
# Fallback: estimate tokens using advanced tokenizer
|
| 519 |
+
response_text = ""
|
| 520 |
+
if 'choices' in response_data:
|
| 521 |
+
for choice in response_data['choices']:
|
| 522 |
+
if 'message' in choice and 'content' in choice['message']:
|
| 523 |
+
response_text += choice['message']['content'] + " "
|
| 524 |
+
|
| 525 |
+
token_result = unified_tokenizer.count_tokens(
|
| 526 |
+
request_data=request_json,
|
| 527 |
+
service="openai",
|
| 528 |
+
model=model,
|
| 529 |
+
response_text=response_text.strip()
|
| 530 |
+
)
|
| 531 |
+
tokens = {
|
| 532 |
+
'prompt_tokens': token_result['prompt_tokens'],
|
| 533 |
+
'completion_tokens': token_result['completion_tokens'],
|
| 534 |
+
'total_tokens': token_result['total_tokens']
|
| 535 |
+
}
|
| 536 |
+
except (json.JSONDecodeError, Exception):
|
| 537 |
+
# Still try to count tokens from request at least
|
| 538 |
+
try:
|
| 539 |
+
token_result = unified_tokenizer.count_tokens(
|
| 540 |
+
request_data=request_json,
|
| 541 |
+
service="openai",
|
| 542 |
+
model=model
|
| 543 |
+
)
|
| 544 |
+
tokens = {
|
| 545 |
+
'prompt_tokens': token_result['prompt_tokens'],
|
| 546 |
+
'completion_tokens': 0,
|
| 547 |
+
'total_tokens': token_result['prompt_tokens']
|
| 548 |
+
}
|
| 549 |
+
except Exception:
|
| 550 |
+
pass
|
| 551 |
+
|
| 552 |
+
metrics.track_request('chat_completions', response_time, error=response.status_code >= 400, tokens=tokens, service='openai')
|
| 553 |
+
|
| 554 |
+
# Track token usage for authenticated users
|
| 555 |
+
if tokens and hasattr(g, 'auth_data') and g.auth_data.get('type') == 'user_token':
|
| 556 |
+
track_token_usage('openai', tokens.get('prompt_tokens', 0), tokens.get('completion_tokens', 0))
|
| 557 |
+
|
| 558 |
+
# Track model usage and get cost
|
| 559 |
+
model_cost = model_manager.track_model_usage(
|
| 560 |
+
user_token=g.auth_data['token'],
|
| 561 |
+
model_id=model,
|
| 562 |
+
input_tokens=tokens.get('prompt_tokens', 0),
|
| 563 |
+
output_tokens=tokens.get('completion_tokens', 0),
|
| 564 |
+
success=response.status_code == 200
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
# Log completion event
|
| 568 |
+
event_logger.log_chat_completion(
|
| 569 |
+
token=g.auth_data['token'],
|
| 570 |
+
model_family='openai',
|
| 571 |
+
input_tokens=tokens.get('prompt_tokens', 0),
|
| 572 |
+
output_tokens=tokens.get('completion_tokens', 0),
|
| 573 |
+
ip_hash=g.auth_data.get('ip', '')
|
| 574 |
+
)
|
| 575 |
+
|
| 576 |
+
# Log structured completion event with model details
|
| 577 |
+
structured_logger.log_chat_completion(
|
| 578 |
+
user_token=g.auth_data['token'],
|
| 579 |
+
model_family='openai',
|
| 580 |
+
model_name=model,
|
| 581 |
+
input_tokens=tokens.get('prompt_tokens', 0),
|
| 582 |
+
output_tokens=tokens.get('completion_tokens', 0),
|
| 583 |
+
cost_usd=model_cost or 0.0,
|
| 584 |
+
response_time_ms=response_time * 1000,
|
| 585 |
+
success=response.status_code == 200,
|
| 586 |
+
ip_hash=g.auth_data.get('ip', ''),
|
| 587 |
+
user_agent=request.headers.get('User-Agent')
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
if is_streaming:
|
| 591 |
+
return Response(
|
| 592 |
+
response.iter_content(chunk_size=1024),
|
| 593 |
+
content_type=response.headers.get('content-type'),
|
| 594 |
+
status=response.status_code
|
| 595 |
+
)
|
| 596 |
+
else:
|
| 597 |
+
return Response(
|
| 598 |
+
response_content,
|
| 599 |
+
content_type=response.headers.get('content-type'),
|
| 600 |
+
status=response.status_code
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
except Exception as e:
|
| 604 |
+
error_msg = str(e)
|
| 605 |
+
if key_manager.handle_api_error('openai', api_key, error_msg):
|
| 606 |
+
if attempt < max_retries - 1:
|
| 607 |
+
time.sleep(1)
|
| 608 |
+
continue
|
| 609 |
+
|
| 610 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 611 |
+
return jsonify({"error": error_msg}), 500
|
| 612 |
+
|
| 613 |
+
# If we get here, all retries failed
|
| 614 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 615 |
+
return jsonify({"error": "All API keys rate limited or failed"}), 429
|
| 616 |
+
|
| 617 |
+
@app.route('/openai/v1/models', methods=['GET'])
|
| 618 |
+
@app.route('/v1/models', methods=['GET']) # Legacy support
|
| 619 |
+
def openai_models():
|
| 620 |
+
"""Return whitelisted OpenAI models instead of proxying to API"""
|
| 621 |
+
start_time = time.time()
|
| 622 |
+
|
| 623 |
+
try:
|
| 624 |
+
# Get whitelisted OpenAI models
|
| 625 |
+
whitelisted_models = model_manager.get_whitelisted_models(AIProvider.OPENAI)
|
| 626 |
+
|
| 627 |
+
# Convert to OpenAI API format
|
| 628 |
+
models_data = []
|
| 629 |
+
for model_info in whitelisted_models:
|
| 630 |
+
models_data.append({
|
| 631 |
+
"id": model_info.model_id,
|
| 632 |
+
"object": "model",
|
| 633 |
+
"created": int(datetime.now().timestamp()),
|
| 634 |
+
"owned_by": "openai",
|
| 635 |
+
"permission": [
|
| 636 |
+
{
|
| 637 |
+
"id": f"modelperm-{model_info.model_id}",
|
| 638 |
+
"object": "model_permission",
|
| 639 |
+
"created": int(datetime.now().timestamp()),
|
| 640 |
+
"allow_create_engine": False,
|
| 641 |
+
"allow_sampling": True,
|
| 642 |
+
"allow_logprobs": True,
|
| 643 |
+
"allow_search_indices": False,
|
| 644 |
+
"allow_view": True,
|
| 645 |
+
"allow_fine_tuning": False,
|
| 646 |
+
"organization": "*",
|
| 647 |
+
"group": None,
|
| 648 |
+
"is_blocking": False
|
| 649 |
+
}
|
| 650 |
+
],
|
| 651 |
+
"root": model_info.model_id,
|
| 652 |
+
"parent": None
|
| 653 |
+
})
|
| 654 |
+
|
| 655 |
+
response_data = {
|
| 656 |
+
"object": "list",
|
| 657 |
+
"data": models_data
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
metrics.track_request('models', time.time() - start_time)
|
| 661 |
+
return jsonify(response_data)
|
| 662 |
+
|
| 663 |
+
except Exception as e:
|
| 664 |
+
metrics.track_request('models', time.time() - start_time, error=True)
|
| 665 |
+
return jsonify({"error": str(e)}), 500
|
| 666 |
+
|
| 667 |
+
@app.route('/api/keys/status', methods=['GET'])
|
| 668 |
+
def key_status():
|
| 669 |
+
"""Check which API keys are configured"""
|
| 670 |
+
start_time = time.time()
|
| 671 |
+
status = {}
|
| 672 |
+
for service, keys in key_manager.api_keys.items():
|
| 673 |
+
status[service] = {
|
| 674 |
+
'configured': len([k for k in keys if k]) > 0,
|
| 675 |
+
'count': len([k for k in keys if k])
|
| 676 |
+
}
|
| 677 |
+
metrics.track_request('key_status', time.time() - start_time)
|
| 678 |
+
return jsonify(status)
|
| 679 |
+
|
| 680 |
+
@app.route('/api/keys/health', methods=['GET'])
|
| 681 |
+
def key_health():
|
| 682 |
+
"""Get detailed health status for all keys"""
|
| 683 |
+
start_time = time.time()
|
| 684 |
+
|
| 685 |
+
# Get health data with safe serialization
|
| 686 |
+
health_data = {}
|
| 687 |
+
with key_manager.lock:
|
| 688 |
+
for key, health in key_manager.key_health.items():
|
| 689 |
+
# Mask the key for security (show only first 8 chars)
|
| 690 |
+
masked_key = key[:8] + '...' if len(key) > 8 else key
|
| 691 |
+
health_data[masked_key] = {
|
| 692 |
+
'status': health['status'],
|
| 693 |
+
'service': health['service'],
|
| 694 |
+
'last_success': health['last_success'].isoformat() if health['last_success'] else None,
|
| 695 |
+
'last_failure': health['last_failure'].isoformat() if health['last_failure'] else None,
|
| 696 |
+
'failure_count': health['failure_count'],
|
| 697 |
+
'consecutive_failures': health['consecutive_failures'],
|
| 698 |
+
'error_type': health['error_type'],
|
| 699 |
+
'last_error': health['last_error'][:100] if health['last_error'] else None # Truncate error message
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
metrics.track_request('key_health', time.time() - start_time)
|
| 703 |
+
return jsonify(health_data)
|
| 704 |
+
|
| 705 |
+
@app.route('/api/debug/keys', methods=['GET'])
|
| 706 |
+
def debug_keys():
|
| 707 |
+
"""Debug endpoint to check key loading"""
|
| 708 |
+
debug_info = {}
|
| 709 |
+
for service, keys in key_manager.api_keys.items():
|
| 710 |
+
debug_info[service] = {
|
| 711 |
+
'total_keys': len(keys),
|
| 712 |
+
'valid_keys': len([k for k in keys if k]),
|
| 713 |
+
'first_few_chars': [k[:8] + '...' if len(k) > 8 else k for k in keys[:3] if k] # Show first 3 for debugging
|
| 714 |
+
}
|
| 715 |
+
return jsonify(debug_info)
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
@app.route('/api/metrics', methods=['GET'])
|
| 719 |
+
def get_metrics():
|
| 720 |
+
"""Get proxy metrics"""
|
| 721 |
+
return jsonify(metrics.get_metrics())
|
| 722 |
+
|
| 723 |
+
# Old admin dashboard route removed - now handled by admin_web_bp
|
| 724 |
+
|
| 725 |
+
@app.route('/', methods=['GET'])
|
| 726 |
+
def dashboard():
|
| 727 |
+
"""Dashboard webpage"""
|
| 728 |
+
metrics_data = metrics.get_metrics()
|
| 729 |
+
key_status_data = {}
|
| 730 |
+
key_health_data = {}
|
| 731 |
+
|
| 732 |
+
for service, keys in key_manager.api_keys.items():
|
| 733 |
+
key_status_data[service] = {
|
| 734 |
+
'configured': len([k for k in keys if k]) > 0,
|
| 735 |
+
'count': len([k for k in keys if k])
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
# Get health status for this service
|
| 740 |
+
service_health = {
|
| 741 |
+
'healthy': 0,
|
| 742 |
+
'rate_limited': 0,
|
| 743 |
+
'invalid_key': 0,
|
| 744 |
+
'quota_exceeded': 0,
|
| 745 |
+
'unknown': 0,
|
| 746 |
+
'failed': 0,
|
| 747 |
+
'key_details': [],
|
| 748 |
+
'successful_requests': 0,
|
| 749 |
+
'total_tokens': 0,
|
| 750 |
+
'avg_response_time': '0.00'
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
# Get metrics data for this service
|
| 754 |
+
if service in metrics_data['service_tokens']:
|
| 755 |
+
service_metrics = metrics_data['service_tokens'][service]
|
| 756 |
+
service_health['successful_requests'] = service_metrics['successful_requests']
|
| 757 |
+
service_health['total_tokens'] = service_metrics['total_tokens']
|
| 758 |
+
|
| 759 |
+
# Calculate average response time for this service
|
| 760 |
+
if service_metrics['response_times']:
|
| 761 |
+
avg_time = sum(service_metrics['response_times']) / len(service_metrics['response_times'])
|
| 762 |
+
service_health['avg_response_time'] = f"{avg_time:.2f}"
|
| 763 |
+
|
| 764 |
+
with key_manager.lock:
|
| 765 |
+
for key in keys:
|
| 766 |
+
if key and key in key_manager.key_health:
|
| 767 |
+
health = key_manager.key_health[key]
|
| 768 |
+
status = health['status']
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
# Count by status
|
| 772 |
+
if status in service_health:
|
| 773 |
+
service_health[status] += 1
|
| 774 |
+
else:
|
| 775 |
+
service_health['failed'] += 1
|
| 776 |
+
|
| 777 |
+
# Add key details (masked for security)
|
| 778 |
+
masked_key = key[:8] + '...' if len(key) > 8 else key
|
| 779 |
+
service_health['key_details'].append({
|
| 780 |
+
'key': masked_key,
|
| 781 |
+
'status': status,
|
| 782 |
+
'last_success': health['last_success'].strftime('%Y-%m-%d %H:%M:%S') if health['last_success'] else 'Never',
|
| 783 |
+
'last_failure': health['last_failure'].strftime('%Y-%m-%d %H:%M:%S') if health['last_failure'] else 'Never',
|
| 784 |
+
'consecutive_failures': health['consecutive_failures'],
|
| 785 |
+
'error_type': health['error_type']
|
| 786 |
+
})
|
| 787 |
+
else:
|
| 788 |
+
service_health['unknown'] += 1
|
| 789 |
+
|
| 790 |
+
key_health_data[service] = service_health
|
| 791 |
+
|
| 792 |
+
uptime_hours = metrics_data['uptime_seconds'] / 3600
|
| 793 |
+
uptime_display = f"{uptime_hours:.1f} hours" if uptime_hours >= 1 else f"{metrics_data['uptime_seconds']:.0f} seconds"
|
| 794 |
+
|
| 795 |
+
# Get current deployment URL
|
| 796 |
+
base_url = get_base_url()
|
| 797 |
+
|
| 798 |
+
# Get configurable dashboard variables from environment
|
| 799 |
+
dashboard_config = {
|
| 800 |
+
'title': f"{os.getenv('BRAND_EMOJI', 'π±')} {os.getenv('DASHBOARD_TITLE', 'NyanProxy Dashboard')}",
|
| 801 |
+
'subtitle': os.getenv('DASHBOARD_SUBTITLE', 'Purr-fect monitoring for your AI service proxy!'),
|
| 802 |
+
'refresh_interval': int(os.getenv('DASHBOARD_REFRESH_INTERVAL', 30)),
|
| 803 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 804 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 805 |
+
'brand_description': os.getenv('BRAND_DESCRIPTION', 'Meow-nificent AI Proxy!'),
|
| 806 |
+
'auth_mode': auth_config.mode,
|
| 807 |
+
'auth_display': {
|
| 808 |
+
'none': 'No Authentication',
|
| 809 |
+
'proxy_key': 'Proxy Key (Shared Password)',
|
| 810 |
+
'user_token': 'User Token (Individual Authentication)'
|
| 811 |
+
}.get(auth_config.mode, 'Unknown')
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
return render_template('dashboard.html',
|
| 815 |
+
base_url=base_url,
|
| 816 |
+
uptime_display=uptime_display,
|
| 817 |
+
total_requests=metrics_data['total_requests'],
|
| 818 |
+
total_prompts=metrics_data['request_counts']['chat_completions'],
|
| 819 |
+
total_tokens=metrics_data['total_tokens'],
|
| 820 |
+
average_response_time=f"{metrics_data['average_response_time']:.2f}s",
|
| 821 |
+
key_status_data=key_status_data,
|
| 822 |
+
key_health_data=key_health_data,
|
| 823 |
+
request_counts=metrics_data['request_counts'],
|
| 824 |
+
error_counts=metrics_data['error_counts'],
|
| 825 |
+
last_request=metrics_data['last_request'],
|
| 826 |
+
config=dashboard_config)
|
| 827 |
+
|
| 828 |
+
def get_base_url():
|
| 829 |
+
"""Get the base URL of the current deployment"""
|
| 830 |
+
# Try to detect various deployment platforms automatically
|
| 831 |
+
|
| 832 |
+
# Hugging Face Spaces - check for HF_SPACE_ID or detect from hostname
|
| 833 |
+
if os.getenv('HF_SPACE_ID'):
|
| 834 |
+
space_id = os.getenv('HF_SPACE_ID')
|
| 835 |
+
space_url = space_id.replace('/', '-')
|
| 836 |
+
return f'https://{space_url}.hf.space'
|
| 837 |
+
|
| 838 |
+
# Render.com
|
| 839 |
+
render_url = os.getenv('RENDER_EXTERNAL_URL')
|
| 840 |
+
if render_url:
|
| 841 |
+
return render_url
|
| 842 |
+
|
| 843 |
+
# Railway
|
| 844 |
+
railway_url = os.getenv('RAILWAY_STATIC_URL')
|
| 845 |
+
if railway_url:
|
| 846 |
+
return railway_url
|
| 847 |
+
|
| 848 |
+
# Heroku
|
| 849 |
+
heroku_app = os.getenv('HEROKU_APP_NAME')
|
| 850 |
+
if heroku_app:
|
| 851 |
+
return f'https://{heroku_app}.herokuapp.com'
|
| 852 |
+
|
| 853 |
+
# Vercel
|
| 854 |
+
vercel_url = os.getenv('VERCEL_URL')
|
| 855 |
+
if vercel_url:
|
| 856 |
+
return f'https://{vercel_url}'
|
| 857 |
+
|
| 858 |
+
# Netlify
|
| 859 |
+
netlify_url = os.getenv('NETLIFY_URL')
|
| 860 |
+
if netlify_url:
|
| 861 |
+
return netlify_url
|
| 862 |
+
|
| 863 |
+
# Generic detection from common environment variables
|
| 864 |
+
app_url = os.getenv('APP_URL')
|
| 865 |
+
if app_url:
|
| 866 |
+
return app_url
|
| 867 |
+
|
| 868 |
+
# Try to detect from request headers (if available)
|
| 869 |
+
try:
|
| 870 |
+
if request and request.headers:
|
| 871 |
+
host = request.headers.get('Host')
|
| 872 |
+
proto = 'https' if request.headers.get('X-Forwarded-Proto') == 'https' else 'http'
|
| 873 |
+
if host and 'localhost' not in host:
|
| 874 |
+
return f'{proto}://{host}'
|
| 875 |
+
except:
|
| 876 |
+
pass
|
| 877 |
+
|
| 878 |
+
# Fallback to localhost for development
|
| 879 |
+
port = os.getenv('PORT', 7860)
|
| 880 |
+
return f'http://localhost:{port}'
|
| 881 |
+
|
| 882 |
+
# Add Anthropic endpoints
|
| 883 |
+
@app.route('/anthropic/v1/messages', methods=['POST'])
|
| 884 |
+
@app.route('/v1/messages', methods=['POST']) # Legacy support
|
| 885 |
+
@app.route('/anthropic/v1', methods=['POST']) # User-friendly endpoint
|
| 886 |
+
@require_auth
|
| 887 |
+
def anthropic_messages():
|
| 888 |
+
"""Proxy Anthropic messages with token tracking"""
|
| 889 |
+
print("DEBUG: Anthropic endpoint called!")
|
| 890 |
+
start_time = time.time()
|
| 891 |
+
max_retries = 3
|
| 892 |
+
|
| 893 |
+
# Check quota for Anthropic models
|
| 894 |
+
has_quota, quota_error = check_quota('anthropic')
|
| 895 |
+
if not has_quota:
|
| 896 |
+
return jsonify({"error": quota_error}), 429
|
| 897 |
+
|
| 898 |
+
for attempt in range(max_retries):
|
| 899 |
+
api_key = key_manager.get_api_key('anthropic')
|
| 900 |
+
if not api_key:
|
| 901 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 902 |
+
return jsonify({"error": "No Anthropic API key configured"}), 500
|
| 903 |
+
|
| 904 |
+
headers = {
|
| 905 |
+
'x-api-key': api_key,
|
| 906 |
+
'Content-Type': 'application/json',
|
| 907 |
+
'anthropic-version': '2023-06-01'
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
try:
|
| 911 |
+
response = requests.post(
|
| 912 |
+
'https://api.anthropic.com/v1/messages',
|
| 913 |
+
headers=headers,
|
| 914 |
+
json=request.json,
|
| 915 |
+
stream=request.json.get('stream', False)
|
| 916 |
+
)
|
| 917 |
+
|
| 918 |
+
response_time = time.time() - start_time
|
| 919 |
+
|
| 920 |
+
# Handle different response codes
|
| 921 |
+
if response.status_code >= 400:
|
| 922 |
+
error_text = response.text
|
| 923 |
+
if key_manager.handle_api_error('anthropic', api_key, error_text, response.status_code):
|
| 924 |
+
if attempt < max_retries - 1:
|
| 925 |
+
time.sleep(1) # Brief pause before retry
|
| 926 |
+
continue
|
| 927 |
+
else:
|
| 928 |
+
# Success - update key health
|
| 929 |
+
key_manager.update_key_health(api_key, True)
|
| 930 |
+
|
| 931 |
+
# Extract token information from response
|
| 932 |
+
tokens = None
|
| 933 |
+
response_content = response.content
|
| 934 |
+
|
| 935 |
+
request_json = request.get_json() if request else {}
|
| 936 |
+
is_streaming = request_json.get('stream', False) if request_json else False
|
| 937 |
+
|
| 938 |
+
print(f"DEBUG: Anthropic response status: {response.status_code}, streaming: {is_streaming}")
|
| 939 |
+
|
| 940 |
+
# Debug: Print raw response to see structure
|
| 941 |
+
if response.status_code == 200:
|
| 942 |
+
try:
|
| 943 |
+
debug_response = json.loads(response_content)
|
| 944 |
+
print(f"DEBUG: Anthropic response structure: {debug_response}")
|
| 945 |
+
except:
|
| 946 |
+
print(f"DEBUG: Could not parse response as JSON")
|
| 947 |
+
|
| 948 |
+
if response.status_code == 200:
|
| 949 |
+
# Always try to count tokens, regardless of streaming
|
| 950 |
+
try:
|
| 951 |
+
response_data = json.loads(response_content)
|
| 952 |
+
|
| 953 |
+
# First try to get tokens from API response
|
| 954 |
+
if 'usage' in response_data:
|
| 955 |
+
usage = response_data['usage']
|
| 956 |
+
tokens = {
|
| 957 |
+
'prompt_tokens': usage.get('input_tokens', 0),
|
| 958 |
+
'completion_tokens': usage.get('output_tokens', 0),
|
| 959 |
+
'total_tokens': usage.get('input_tokens', 0) + usage.get('output_tokens', 0)
|
| 960 |
+
}
|
| 961 |
+
else:
|
| 962 |
+
# Fallback: use tokenizer to estimate
|
| 963 |
+
response_text = ""
|
| 964 |
+
if 'content' in response_data:
|
| 965 |
+
for content in response_data['content']:
|
| 966 |
+
if content.get('type') == 'text':
|
| 967 |
+
response_text += content.get('text', '') + " "
|
| 968 |
+
|
| 969 |
+
# Use tokenizer to count tokens
|
| 970 |
+
token_result = unified_tokenizer.count_tokens(
|
| 971 |
+
request_data=request_json,
|
| 972 |
+
service="anthropic",
|
| 973 |
+
response_text=response_text.strip() if response_text else None
|
| 974 |
+
)
|
| 975 |
+
tokens = {
|
| 976 |
+
'prompt_tokens': token_result['prompt_tokens'],
|
| 977 |
+
'completion_tokens': token_result['completion_tokens'],
|
| 978 |
+
'total_tokens': token_result['total_tokens']
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
except Exception as e:
|
| 982 |
+
# Fallback: count request tokens at minimum
|
| 983 |
+
try:
|
| 984 |
+
# Extract text from request for token counting
|
| 985 |
+
request_text = ""
|
| 986 |
+
if 'messages' in request_json:
|
| 987 |
+
for msg in request_json['messages']:
|
| 988 |
+
if 'content' in msg:
|
| 989 |
+
request_text += str(msg['content']) + " "
|
| 990 |
+
if 'system' in request_json:
|
| 991 |
+
request_text += str(request_json['system']) + " "
|
| 992 |
+
|
| 993 |
+
# Use simple character-based estimation if tokenizer fails
|
| 994 |
+
estimated_prompt_tokens = max(len(request_text) // 4, 100) # ~4 chars per token
|
| 995 |
+
estimated_completion_tokens = 50 # Default completion estimate
|
| 996 |
+
|
| 997 |
+
tokens = {
|
| 998 |
+
'prompt_tokens': estimated_prompt_tokens,
|
| 999 |
+
'completion_tokens': estimated_completion_tokens,
|
| 1000 |
+
'total_tokens': estimated_prompt_tokens + estimated_completion_tokens
|
| 1001 |
+
}
|
| 1002 |
+
except Exception:
|
| 1003 |
+
# Absolute fallback
|
| 1004 |
+
tokens = {
|
| 1005 |
+
'prompt_tokens': 1000, # Higher default for large requests
|
| 1006 |
+
'completion_tokens': 100,
|
| 1007 |
+
'total_tokens': 1100
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
print(f"DEBUG: Anthropic - About to track request with tokens={tokens}, error={response.status_code >= 400}, service=anthropic")
|
| 1011 |
+
metrics.track_request('chat_completions', response_time, error=response.status_code >= 400, tokens=tokens, service='anthropic')
|
| 1012 |
+
|
| 1013 |
+
# Track token usage for authenticated users
|
| 1014 |
+
if tokens and hasattr(g, 'auth_data') and g.auth_data.get('type') == 'user_token':
|
| 1015 |
+
track_token_usage('anthropic', tokens.get('prompt_tokens', 0), tokens.get('completion_tokens', 0))
|
| 1016 |
+
|
| 1017 |
+
# Log completion event
|
| 1018 |
+
event_logger.log_chat_completion(
|
| 1019 |
+
token=g.auth_data['token'],
|
| 1020 |
+
model_family='anthropic',
|
| 1021 |
+
input_tokens=tokens.get('prompt_tokens', 0),
|
| 1022 |
+
output_tokens=tokens.get('completion_tokens', 0),
|
| 1023 |
+
ip_hash=g.auth_data.get('ip', '')
|
| 1024 |
+
)
|
| 1025 |
+
|
| 1026 |
+
if is_streaming:
|
| 1027 |
+
return Response(
|
| 1028 |
+
response.iter_content(chunk_size=1024),
|
| 1029 |
+
content_type=response.headers.get('content-type'),
|
| 1030 |
+
status=response.status_code
|
| 1031 |
+
)
|
| 1032 |
+
else:
|
| 1033 |
+
return Response(
|
| 1034 |
+
response_content,
|
| 1035 |
+
content_type=response.headers.get('content-type'),
|
| 1036 |
+
status=response.status_code
|
| 1037 |
+
)
|
| 1038 |
+
|
| 1039 |
+
except Exception as e:
|
| 1040 |
+
error_msg = str(e)
|
| 1041 |
+
if key_manager.handle_api_error('anthropic', api_key, error_msg):
|
| 1042 |
+
if attempt < max_retries - 1:
|
| 1043 |
+
time.sleep(1)
|
| 1044 |
+
continue
|
| 1045 |
+
|
| 1046 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 1047 |
+
return jsonify({"error": error_msg}), 500
|
| 1048 |
+
|
| 1049 |
+
# If we get here, all retries failed
|
| 1050 |
+
metrics.track_request('chat_completions', time.time() - start_time, error=True)
|
| 1051 |
+
return jsonify({"error": "All API keys rate limited or failed"}), 429
|
| 1052 |
+
|
| 1053 |
+
@app.route('/anthropic/v1/models', methods=['GET'])
|
| 1054 |
+
def anthropic_models():
|
| 1055 |
+
"""Anthropic models endpoint (placeholder)"""
|
| 1056 |
+
return jsonify({"error": "Anthropic models endpoint not implemented yet"}), 501
|
| 1057 |
+
|
health_checker.py β core/health_checker.py
RENAMED
|
@@ -146,7 +146,7 @@ class AnthropicHealthChecker(BaseHealthChecker):
|
|
| 146 |
response = requests.post(
|
| 147 |
self.messages_url,
|
| 148 |
headers={
|
| 149 |
-
'
|
| 150 |
'Content-Type': 'application/json',
|
| 151 |
'anthropic-version': '2023-06-01'
|
| 152 |
},
|
|
|
|
| 146 |
response = requests.post(
|
| 147 |
self.messages_url,
|
| 148 |
headers={
|
| 149 |
+
'x-api-key': api_key,
|
| 150 |
'Content-Type': 'application/json',
|
| 151 |
'anthropic-version': '2023-06-01'
|
| 152 |
},
|
pages/admin/anti_abuse.html
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Anti-Abuse Settings{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="anti-abuse">
|
| 7 |
+
<h1>Anti-Abuse Configuration</h1>
|
| 8 |
+
|
| 9 |
+
<div class="settings-section">
|
| 10 |
+
<h2>Rate Limiting</h2>
|
| 11 |
+
<form method="POST" action="{{ url_for('admin.anti_abuse') }}">
|
| 12 |
+
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
| 13 |
+
|
| 14 |
+
<div class="form-group">
|
| 15 |
+
<label>
|
| 16 |
+
<input type="checkbox" name="rate_limit_enabled" checked>
|
| 17 |
+
Enable Rate Limiting
|
| 18 |
+
</label>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="form-group">
|
| 22 |
+
<label for="rate_limit_per_minute">Requests per minute per user:</label>
|
| 23 |
+
<input type="number" id="rate_limit_per_minute" name="rate_limit_per_minute"
|
| 24 |
+
value="60" min="1" max="1000">
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div class="form-group">
|
| 28 |
+
<label for="max_ips_per_user">Maximum IP addresses per user:</label>
|
| 29 |
+
<input type="number" id="max_ips_per_user" name="max_ips_per_user"
|
| 30 |
+
value="3" min="1" max="20">
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div class="form-group">
|
| 34 |
+
<label>
|
| 35 |
+
<input type="checkbox" name="max_ips_auto_ban" checked>
|
| 36 |
+
Auto-ban users who exceed IP limit
|
| 37 |
+
</label>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<button type="submit" class="btn btn-primary">Save Settings</button>
|
| 41 |
+
</form>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="settings-section">
|
| 45 |
+
<h2>Current Statistics</h2>
|
| 46 |
+
<div class="stats-grid">
|
| 47 |
+
<div class="stat-card">
|
| 48 |
+
<h3>Rate Limited Requests</h3>
|
| 49 |
+
<p class="stat-number">0</p>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="stat-card">
|
| 52 |
+
<h3>Auto-banned Users</h3>
|
| 53 |
+
<p class="stat-number">0</p>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="stat-card">
|
| 56 |
+
<h3>Blocked IPs</h3>
|
| 57 |
+
<p class="stat-number">0</p>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<style>
|
| 64 |
+
.settings-section {
|
| 65 |
+
margin-bottom: 40px;
|
| 66 |
+
padding: 20px;
|
| 67 |
+
background: #f8f9fa;
|
| 68 |
+
border-radius: 5px;
|
| 69 |
+
}
|
| 70 |
+
.form-group {
|
| 71 |
+
margin-bottom: 15px;
|
| 72 |
+
}
|
| 73 |
+
.form-group label {
|
| 74 |
+
display: block;
|
| 75 |
+
margin-bottom: 5px;
|
| 76 |
+
font-weight: bold;
|
| 77 |
+
}
|
| 78 |
+
.form-group input[type="number"] {
|
| 79 |
+
width: 200px;
|
| 80 |
+
padding: 8px;
|
| 81 |
+
border: 1px solid #ddd;
|
| 82 |
+
border-radius: 3px;
|
| 83 |
+
}
|
| 84 |
+
.form-group input[type="checkbox"] {
|
| 85 |
+
margin-right: 10px;
|
| 86 |
+
}
|
| 87 |
+
.stats-grid {
|
| 88 |
+
display: grid;
|
| 89 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 90 |
+
gap: 20px;
|
| 91 |
+
margin-top: 20px;
|
| 92 |
+
}
|
| 93 |
+
.stat-card {
|
| 94 |
+
background: white;
|
| 95 |
+
padding: 20px;
|
| 96 |
+
border-radius: 5px;
|
| 97 |
+
text-align: center;
|
| 98 |
+
border: 1px solid #dee2e6;
|
| 99 |
+
}
|
| 100 |
+
.stat-number {
|
| 101 |
+
font-size: 2em;
|
| 102 |
+
font-weight: bold;
|
| 103 |
+
color: #007bff;
|
| 104 |
+
margin: 10px 0;
|
| 105 |
+
}
|
| 106 |
+
</style>
|
| 107 |
+
{% endblock %}
|
pages/admin/base.html
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}Admin Dashboard{% endblock %} - {{ config.brand_name }}</title>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/nyancat.css') }}">
|
| 8 |
+
<meta name="csrf-token" content="{{ csrf_token() }}">
|
| 9 |
+
|
| 10 |
+
<style>
|
| 11 |
+
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap');
|
| 12 |
+
|
| 13 |
+
/* Beautiful animated background from main dashboard */
|
| 14 |
+
body {
|
| 15 |
+
font-family: 'Quicksand', sans-serif;
|
| 16 |
+
margin: 0;
|
| 17 |
+
padding: 20px;
|
| 18 |
+
background: linear-gradient(135deg, #ffd3e1 0%, #c8e6c9 25%, #bbdefb 50%, #f8bbd9 75%, #ffcccb 100%);
|
| 19 |
+
background-size: 400% 400%;
|
| 20 |
+
min-height: 100vh;
|
| 21 |
+
animation: pastelGradient 15s ease-in-out infinite;
|
| 22 |
+
position: relative;
|
| 23 |
+
overflow-x: hidden;
|
| 24 |
+
max-width: none !important;
|
| 25 |
+
width: 100% !important;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
@keyframes pastelGradient {
|
| 29 |
+
0% { background-position: 0% 50%; }
|
| 30 |
+
25% { background-position: 100% 50%; }
|
| 31 |
+
50% { background-position: 50% 100%; }
|
| 32 |
+
75% { background-position: 50% 0%; }
|
| 33 |
+
100% { background-position: 0% 50%; }
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Container to center everything */
|
| 37 |
+
.admin-container {
|
| 38 |
+
max-width: 1600px;
|
| 39 |
+
width: 95%;
|
| 40 |
+
margin: 0 auto;
|
| 41 |
+
position: relative;
|
| 42 |
+
display: flex;
|
| 43 |
+
flex-direction: column;
|
| 44 |
+
align-items: center;
|
| 45 |
+
min-height: 100vh;
|
| 46 |
+
justify-content: flex-start;
|
| 47 |
+
padding: 0 20px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Beautiful header with glassmorphism */
|
| 51 |
+
.nyan-header {
|
| 52 |
+
background: rgba(255, 255, 255, 0.95) !important;
|
| 53 |
+
backdrop-filter: blur(10px) !important;
|
| 54 |
+
color: #333 !important;
|
| 55 |
+
padding: 30px !important;
|
| 56 |
+
border-radius: 20px !important;
|
| 57 |
+
margin-bottom: 30px !important;
|
| 58 |
+
text-align: center !important;
|
| 59 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.1) !important;
|
| 60 |
+
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
| 61 |
+
position: relative !important;
|
| 62 |
+
overflow: visible !important;
|
| 63 |
+
text-shadow: none !important;
|
| 64 |
+
width: 100% !important;
|
| 65 |
+
max-width: 100% !important;
|
| 66 |
+
box-sizing: border-box !important;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.nyan-header::before {
|
| 70 |
+
content: '';
|
| 71 |
+
position: absolute;
|
| 72 |
+
top: -50%;
|
| 73 |
+
left: -50%;
|
| 74 |
+
width: 200%;
|
| 75 |
+
height: 200%;
|
| 76 |
+
background: repeating-linear-gradient(
|
| 77 |
+
45deg,
|
| 78 |
+
transparent,
|
| 79 |
+
transparent 10px,
|
| 80 |
+
rgba(255, 182, 193, 0.1) 10px,
|
| 81 |
+
rgba(255, 182, 193, 0.1) 20px
|
| 82 |
+
);
|
| 83 |
+
animation: rainbow 3s linear infinite;
|
| 84 |
+
z-index: -1;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
@keyframes rainbow {
|
| 88 |
+
0% { transform: translateX(-100%) translateY(-100%) rotate(0deg); }
|
| 89 |
+
100% { transform: translateX(-100%) translateY(-100%) rotate(360deg); }
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.nyan-header h1 {
|
| 93 |
+
font-size: 2em !important;
|
| 94 |
+
margin: 0 0 20px 0 !important;
|
| 95 |
+
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #f9ca24) !important;
|
| 96 |
+
-webkit-background-clip: text !important;
|
| 97 |
+
-webkit-text-fill-color: transparent !important;
|
| 98 |
+
background-clip: text !important;
|
| 99 |
+
animation: bounce 2s ease-in-out infinite !important;
|
| 100 |
+
font-weight: 600 !important;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@keyframes bounce {
|
| 104 |
+
0%, 20%, 50%, 80%, 100% { transform: translateY(0); }
|
| 105 |
+
40% { transform: translateY(-10px); }
|
| 106 |
+
60% { transform: translateY(-5px); }
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.nyan-header ul {
|
| 110 |
+
list-style: none !important;
|
| 111 |
+
padding: 0 !important;
|
| 112 |
+
margin: 0 !important;
|
| 113 |
+
display: flex !important;
|
| 114 |
+
flex-wrap: wrap !important;
|
| 115 |
+
gap: 10px !important;
|
| 116 |
+
justify-content: center !important;
|
| 117 |
+
align-items: center !important;
|
| 118 |
+
width: 100% !important;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.nyan-header li {
|
| 122 |
+
margin: 0 !important;
|
| 123 |
+
flex-shrink: 0 !important;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.nyan-header a {
|
| 127 |
+
color: #333 !important;
|
| 128 |
+
text-decoration: none !important;
|
| 129 |
+
padding: 10px 16px !important;
|
| 130 |
+
border-radius: 25px !important;
|
| 131 |
+
font-size: 0.9em !important;
|
| 132 |
+
font-weight: 500 !important;
|
| 133 |
+
transition: all 0.3s ease !important;
|
| 134 |
+
background: rgba(255, 255, 255, 0.7) !important;
|
| 135 |
+
border: 1px solid rgba(255, 255, 255, 0.5) !important;
|
| 136 |
+
display: inline-block !important;
|
| 137 |
+
white-space: nowrap !important;
|
| 138 |
+
min-width: max-content !important;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.nyan-header a:hover {
|
| 142 |
+
background: rgba(255, 255, 255, 0.95) !important;
|
| 143 |
+
transform: translateY(-3px) !important;
|
| 144 |
+
box-shadow: 0 6px 20px rgba(0,0,0,0.15) !important;
|
| 145 |
+
color: #007bff !important;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/* Main content area with glassmorphism */
|
| 149 |
+
main {
|
| 150 |
+
width: 100% !important;
|
| 151 |
+
max-width: 100% !important;
|
| 152 |
+
margin: 0 !important;
|
| 153 |
+
padding: 40px !important;
|
| 154 |
+
background: rgba(255, 255, 255, 0.9) !important;
|
| 155 |
+
backdrop-filter: blur(10px) !important;
|
| 156 |
+
border-radius: 20px !important;
|
| 157 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.1) !important;
|
| 158 |
+
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
| 159 |
+
position: relative !important;
|
| 160 |
+
overflow: hidden !important;
|
| 161 |
+
min-height: calc(100vh - 300px) !important;
|
| 162 |
+
box-sizing: border-box !important;
|
| 163 |
+
display: flex !important;
|
| 164 |
+
flex-direction: column !important;
|
| 165 |
+
align-items: center !important;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
main::before {
|
| 169 |
+
content: '';
|
| 170 |
+
position: absolute;
|
| 171 |
+
top: -2px;
|
| 172 |
+
left: -2px;
|
| 173 |
+
right: -2px;
|
| 174 |
+
bottom: -2px;
|
| 175 |
+
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #f9ca24);
|
| 176 |
+
border-radius: 22px;
|
| 177 |
+
z-index: -1;
|
| 178 |
+
opacity: 0.1;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
footer {
|
| 182 |
+
text-align: center !important;
|
| 183 |
+
padding: 20px !important;
|
| 184 |
+
color: rgba(255, 255, 255, 0.8) !important;
|
| 185 |
+
font-size: 0.9em !important;
|
| 186 |
+
background: rgba(255, 255, 255, 0.1) !important;
|
| 187 |
+
margin-top: 20px !important;
|
| 188 |
+
border-radius: 15px !important;
|
| 189 |
+
backdrop-filter: blur(5px) !important;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/* Flash messages with beautiful styling */
|
| 193 |
+
.flash {
|
| 194 |
+
padding: 15px 20px !important;
|
| 195 |
+
margin-bottom: 20px !important;
|
| 196 |
+
border-radius: 15px !important;
|
| 197 |
+
font-size: 0.9em !important;
|
| 198 |
+
backdrop-filter: blur(5px) !important;
|
| 199 |
+
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.flash-success {
|
| 203 |
+
background: rgba(166, 230, 207, 0.9) !important;
|
| 204 |
+
color: #155724 !important;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.flash-error {
|
| 208 |
+
background: rgba(255, 205, 210, 0.9) !important;
|
| 209 |
+
color: #721c24 !important;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.flash-info {
|
| 213 |
+
background: rgba(209, 236, 241, 0.9) !important;
|
| 214 |
+
color: #0c5460 !important;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.flash-warning {
|
| 218 |
+
background: rgba(255, 243, 205, 0.9) !important;
|
| 219 |
+
color: #856404 !important;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* Floating paws animations */
|
| 223 |
+
.paw-print {
|
| 224 |
+
position: absolute;
|
| 225 |
+
color: rgba(255, 182, 193, 0.3);
|
| 226 |
+
font-size: 1.2em;
|
| 227 |
+
animation: float 3s ease-in-out infinite;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
@keyframes float {
|
| 231 |
+
0%, 100% { transform: translateY(0px); }
|
| 232 |
+
50% { transform: translateY(-10px); }
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/* Cat paw falling animations */
|
| 236 |
+
#catpaws {
|
| 237 |
+
position: fixed;
|
| 238 |
+
top: -50px;
|
| 239 |
+
width: 100%;
|
| 240 |
+
text-align: right;
|
| 241 |
+
z-index: 10000;
|
| 242 |
+
pointer-events: none;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
#catpaws i {
|
| 246 |
+
display: inline-block;
|
| 247 |
+
width: 40px;
|
| 248 |
+
height: 40px;
|
| 249 |
+
font-size: 2em;
|
| 250 |
+
z-index: 10000;
|
| 251 |
+
-webkit-animation: falling 5s 0s infinite;
|
| 252 |
+
animation: falling 5s 0s infinite;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
#catpaws i:nth-of-type(2n) {
|
| 256 |
+
-webkit-animation: falling2 5s 0s infinite;
|
| 257 |
+
animation: falling2 5s 0s infinite;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
#catpaws i:nth-of-type(3n) {
|
| 261 |
+
-webkit-animation: falling3 5s 0s infinite;
|
| 262 |
+
animation: falling3 5s 0s infinite;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
#catpaws i:nth-of-type(n) { height: 23px; width: 30px; font-size: 1.5em; }
|
| 266 |
+
#catpaws i:nth-of-type(2n+1) { height: 18px; width: 24px; font-size: 1.2em; }
|
| 267 |
+
#catpaws i:nth-of-type(3n+2) { height: 28px; width: 35px; font-size: 1.8em; }
|
| 268 |
+
|
| 269 |
+
#catpaws i:nth-of-type(n) { -webkit-animation-delay: 1.9s; animation-delay: 1.9s; }
|
| 270 |
+
#catpaws i:nth-of-type(2n) { -webkit-animation-delay: 3.9s; animation-delay: 3.9s; }
|
| 271 |
+
#catpaws i:nth-of-type(3n) { -webkit-animation-delay: 2.3s; animation-delay: 2.3s; }
|
| 272 |
+
#catpaws i:nth-of-type(4n) { -webkit-animation-delay: 4.4s; animation-delay: 4.4s; }
|
| 273 |
+
#catpaws i:nth-of-type(5n) { -webkit-animation-delay: 5s; animation-delay: 5s; }
|
| 274 |
+
#catpaws i:nth-of-type(6n) { -webkit-animation-delay: 3.5s; animation-delay: 3.5s; }
|
| 275 |
+
#catpaws i:nth-of-type(7n) { -webkit-animation-delay: 2.8s; animation-delay: 2.8s; }
|
| 276 |
+
#catpaws i:nth-of-type(8n) { -webkit-animation-delay: 1.5s; animation-delay: 1.5s; }
|
| 277 |
+
#catpaws i:nth-of-type(9n) { -webkit-animation-delay: 3.3s; animation-delay: 3.3s; }
|
| 278 |
+
#catpaws i:nth-of-type(10n) { -webkit-animation-delay: 2.5s; animation-delay: 2.5s; }
|
| 279 |
+
#catpaws i:nth-of-type(11n) { -webkit-animation-delay: 1.2s; animation-delay: 1.2s; }
|
| 280 |
+
#catpaws i:nth-of-type(12n) { -webkit-animation-delay: 4.1s; animation-delay: 4.1s; }
|
| 281 |
+
#catpaws i:nth-of-type(13n) { -webkit-animation-delay: 1s; animation-delay: 1s; }
|
| 282 |
+
#catpaws i:nth-of-type(14n) { -webkit-animation-delay: 4.7s; animation-delay: 4.7s; }
|
| 283 |
+
#catpaws i:nth-of-type(15n) { -webkit-animation-delay: 3s; animation-delay: 3s; }
|
| 284 |
+
|
| 285 |
+
#catpaws i:nth-of-type(n) { opacity: 0.7; color: rgba(255, 182, 193, 0.8); }
|
| 286 |
+
#catpaws i:nth-of-type(3n+1) { opacity: 0.5; color: rgba(255, 192, 203, 0.9); }
|
| 287 |
+
#catpaws i:nth-of-type(3n+2) { opacity: 0.3; color: rgba(255, 160, 180, 0.7); }
|
| 288 |
+
|
| 289 |
+
#catpaws i:nth-of-type(n) { transform: rotate(180deg); }
|
| 290 |
+
#catpaws i:nth-of-type(n) { -webkit-animation-timing-function: ease-in-out; animation-timing-function: ease-in-out; }
|
| 291 |
+
|
| 292 |
+
@-webkit-keyframes falling {
|
| 293 |
+
0% { -webkit-transform: translate3d(300px, 0, 0) rotate(0deg); }
|
| 294 |
+
100% { -webkit-transform: translate3d(-350px, 700px, 0) rotate(90deg); opacity: 0; }
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
@keyframes falling {
|
| 298 |
+
0% { transform: translate3d(300px, 0, 0) rotate(0deg); }
|
| 299 |
+
100% { transform: translate3d(-350px, 700px, 0) rotate(90deg); opacity: 0; }
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
@-webkit-keyframes falling3 {
|
| 303 |
+
0% { -webkit-transform: translate3d(0, 0, 0) rotate(-20deg); }
|
| 304 |
+
100% { -webkit-transform: translate3d(-230px, 640px, 0) rotate(-70deg); opacity: 0; }
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
@keyframes falling3 {
|
| 308 |
+
0% { transform: translate3d(0, 0, 0) rotate(-20deg); }
|
| 309 |
+
100% { transform: translate3d(-230px, 640px, 0) rotate(-70deg); opacity: 0; }
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
@-webkit-keyframes falling2 {
|
| 313 |
+
0% { -webkit-transform: translate3d(0, 0, 0) rotate(90deg); }
|
| 314 |
+
100% { -webkit-transform: translate3d(-400px, 680px, 0) rotate(0deg); opacity: 0; }
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
@keyframes falling2 {
|
| 318 |
+
0% { transform: translate3d(0, 0, 0) rotate(90deg); }
|
| 319 |
+
100% { transform: translate3d(-400px, 680px, 0) rotate(0deg); opacity: 0; }
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* Responsive design */
|
| 323 |
+
@media (max-width: 768px) {
|
| 324 |
+
body { padding: 10px; }
|
| 325 |
+
.admin-container {
|
| 326 |
+
width: 95%;
|
| 327 |
+
margin: 0 auto;
|
| 328 |
+
}
|
| 329 |
+
.nyan-header { padding: 20px !important; }
|
| 330 |
+
.nyan-header h1 { font-size: 1.5em !important; margin-bottom: 15px !important; }
|
| 331 |
+
.nyan-header ul {
|
| 332 |
+
flex-direction: column !important;
|
| 333 |
+
gap: 8px !important;
|
| 334 |
+
align-items: center !important;
|
| 335 |
+
}
|
| 336 |
+
.nyan-header a {
|
| 337 |
+
display: block !important;
|
| 338 |
+
text-align: center !important;
|
| 339 |
+
width: 200px !important;
|
| 340 |
+
}
|
| 341 |
+
main { padding: 20px !important; }
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
@media (max-width: 1650px) {
|
| 345 |
+
.admin-container {
|
| 346 |
+
width: 95%;
|
| 347 |
+
margin: 0 auto;
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
@media (min-width: 1651px) {
|
| 352 |
+
.admin-container {
|
| 353 |
+
width: 90%;
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
</style>
|
| 357 |
+
|
| 358 |
+
{% block head %}{% endblock %}
|
| 359 |
+
</head>
|
| 360 |
+
<body>
|
| 361 |
+
<!-- Falling cat paws animation -->
|
| 362 |
+
<div id="catpaws">
|
| 363 |
+
<i>πΎ</i>
|
| 364 |
+
<i>πΎ</i>
|
| 365 |
+
<i>πΎ</i>
|
| 366 |
+
<i>πΎ</i>
|
| 367 |
+
<i>πΎ</i>
|
| 368 |
+
<i>πΎ</i>
|
| 369 |
+
<i>πΎ</i>
|
| 370 |
+
<i>πΎ</i>
|
| 371 |
+
<i>πΎ</i>
|
| 372 |
+
<i>πΎ</i>
|
| 373 |
+
<i>πΎ</i>
|
| 374 |
+
<i>πΎ</i>
|
| 375 |
+
<i>πΎ</i>
|
| 376 |
+
<i>πΎ</i>
|
| 377 |
+
<i>πΎ</i>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<div class="admin-container">
|
| 381 |
+
<header>
|
| 382 |
+
<nav class="nyan-header">
|
| 383 |
+
<h1>{{ config.brand_emoji }} {{ config.brand_name }} Admin Purr-nel</h1>
|
| 384 |
+
<ul>
|
| 385 |
+
<li><a href="{{ url_for('admin.dashboard') }}">πΎ Kitty Dashboard</a></li>
|
| 386 |
+
<li><a href="{{ url_for('admin.list_users') }}">π± Manage Cats</a></li>
|
| 387 |
+
<li><a href="{{ url_for('admin.key_manager') }}">π Key Treats</a></li>
|
| 388 |
+
<li><a href="{{ url_for('model_families.model_families_dashboard') }}">𧬠Model Families</a></li>
|
| 389 |
+
<li><a href="{{ url_for('admin.anti_abuse') }}">π‘οΈ Anti-Hairball</a></li>
|
| 390 |
+
<li><a href="{{ url_for('admin.stats') }}">π Paw-lytics</a></li>
|
| 391 |
+
<li><a href="{{ url_for('admin.logout') }}">π΄ Cat Nap</a></li>
|
| 392 |
+
</ul>
|
| 393 |
+
</nav>
|
| 394 |
+
</header>
|
| 395 |
+
|
| 396 |
+
<main>
|
| 397 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 398 |
+
{% if messages %}
|
| 399 |
+
{% for category, message in messages %}
|
| 400 |
+
<div class="flash flash-{{ category }}">
|
| 401 |
+
{{ message }}
|
| 402 |
+
</div>
|
| 403 |
+
{% endfor %}
|
| 404 |
+
{% endif %}
|
| 405 |
+
{% endwith %}
|
| 406 |
+
|
| 407 |
+
{% block content %}{% endblock %}
|
| 408 |
+
</main>
|
| 409 |
+
|
| 410 |
+
<footer>
|
| 411 |
+
<p>© {{ config.brand_name }} Admin Panel</p>
|
| 412 |
+
</footer>
|
| 413 |
+
</div>
|
| 414 |
+
|
| 415 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|
| 416 |
+
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
| 417 |
+
{% block scripts %}{% endblock %}
|
| 418 |
+
</body>
|
| 419 |
+
</html>
|
pages/admin/bulk_operations.html
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Bulk Operations{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="bulk-operations">
|
| 7 |
+
<h1>Bulk Operations</h1>
|
| 8 |
+
|
| 9 |
+
<div class="operation-section">
|
| 10 |
+
<h2>Quota Management</h2>
|
| 11 |
+
<div class="operation-card">
|
| 12 |
+
<h3>Refresh All User Quotas</h3>
|
| 13 |
+
<p>Reset token usage counters for all users</p>
|
| 14 |
+
<button class="btn btn-warning" onclick="bulkRefreshQuotas()">Refresh All Quotas</button>
|
| 15 |
+
</div>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="operation-section">
|
| 19 |
+
<h2>User Management</h2>
|
| 20 |
+
<div class="operation-card">
|
| 21 |
+
<h3>Bulk User Import</h3>
|
| 22 |
+
<p>Import multiple users from JSON file</p>
|
| 23 |
+
<input type="file" id="import-file" accept=".json">
|
| 24 |
+
<button class="btn btn-primary" onclick="importUsers()">Import Users</button>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div class="operation-card">
|
| 28 |
+
<h3>Export All Users</h3>
|
| 29 |
+
<p>Export all user data to JSON file</p>
|
| 30 |
+
<button class="btn btn-info" onclick="exportUsers()">Export Users</button>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div class="operation-section">
|
| 35 |
+
<h2>Cleanup Operations</h2>
|
| 36 |
+
<div class="operation-card">
|
| 37 |
+
<h3>Clean Expired Temporary Users</h3>
|
| 38 |
+
<p>Remove all expired temporary users</p>
|
| 39 |
+
<button class="btn btn-danger" onclick="cleanupExpiredUsers()">Cleanup Expired</button>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<style>
|
| 45 |
+
.operation-section {
|
| 46 |
+
margin-bottom: 40px;
|
| 47 |
+
}
|
| 48 |
+
.operation-card {
|
| 49 |
+
background: #f8f9fa;
|
| 50 |
+
padding: 20px;
|
| 51 |
+
border-radius: 5px;
|
| 52 |
+
margin-bottom: 20px;
|
| 53 |
+
border: 1px solid #dee2e6;
|
| 54 |
+
}
|
| 55 |
+
.operation-card h3 {
|
| 56 |
+
margin-top: 0;
|
| 57 |
+
margin-bottom: 10px;
|
| 58 |
+
color: #495057;
|
| 59 |
+
}
|
| 60 |
+
.operation-card p {
|
| 61 |
+
margin-bottom: 15px;
|
| 62 |
+
color: #6c757d;
|
| 63 |
+
}
|
| 64 |
+
.operation-card input[type="file"] {
|
| 65 |
+
margin-right: 10px;
|
| 66 |
+
margin-bottom: 10px;
|
| 67 |
+
}
|
| 68 |
+
</style>
|
| 69 |
+
|
| 70 |
+
<script>
|
| 71 |
+
function importUsers() {
|
| 72 |
+
const fileInput = document.getElementById('import-file');
|
| 73 |
+
const file = fileInput.files[0];
|
| 74 |
+
|
| 75 |
+
if (!file) {
|
| 76 |
+
alert('Please select a file to import');
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const formData = new FormData();
|
| 81 |
+
formData.append('file', file);
|
| 82 |
+
|
| 83 |
+
fetch('/admin/bulk/import-users', {
|
| 84 |
+
method: 'POST',
|
| 85 |
+
headers: {
|
| 86 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 87 |
+
},
|
| 88 |
+
body: formData
|
| 89 |
+
})
|
| 90 |
+
.then(response => response.json())
|
| 91 |
+
.then(data => {
|
| 92 |
+
if (data.success) {
|
| 93 |
+
alert(`Successfully imported ${data.imported} users`);
|
| 94 |
+
fileInput.value = '';
|
| 95 |
+
} else {
|
| 96 |
+
alert('Error: ' + data.error);
|
| 97 |
+
}
|
| 98 |
+
})
|
| 99 |
+
.catch(error => {
|
| 100 |
+
console.error('Error importing users:', error);
|
| 101 |
+
alert('Error importing users');
|
| 102 |
+
});
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function cleanupExpiredUsers() {
|
| 106 |
+
if (confirm('Are you sure you want to remove all expired temporary users?')) {
|
| 107 |
+
fetch('/admin/bulk/cleanup-expired', {
|
| 108 |
+
method: 'POST',
|
| 109 |
+
headers: {
|
| 110 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 111 |
+
}
|
| 112 |
+
})
|
| 113 |
+
.then(response => response.json())
|
| 114 |
+
.then(data => {
|
| 115 |
+
if (data.success) {
|
| 116 |
+
alert(`Successfully removed ${data.removed} expired users`);
|
| 117 |
+
} else {
|
| 118 |
+
alert('Error: ' + data.error);
|
| 119 |
+
}
|
| 120 |
+
})
|
| 121 |
+
.catch(error => {
|
| 122 |
+
console.error('Error cleaning up users:', error);
|
| 123 |
+
alert('Error cleaning up users');
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
</script>
|
| 128 |
+
{% endblock %}
|
pages/admin/create_user.html
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Create User{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="create-user">
|
| 7 |
+
<h1>Create User Token</h1>
|
| 8 |
+
|
| 9 |
+
{% if new_user_created %}
|
| 10 |
+
<div class="success-message">
|
| 11 |
+
<h3>User Created Successfully!</h3>
|
| 12 |
+
<p>A new user token has been created and is now ready to use. Check the recent users list below to see the new user.</p>
|
| 13 |
+
</div>
|
| 14 |
+
{% endif %}
|
| 15 |
+
|
| 16 |
+
<div class="user-types-info">
|
| 17 |
+
<h3>User Token Types:</h3>
|
| 18 |
+
<ul>
|
| 19 |
+
<li><strong>Normal</strong> - Standard users with default quotas and rate limits.</li>
|
| 20 |
+
<li><strong>Special</strong> - Exempt from token quotas and rate limiting. Higher privileges.</li>
|
| 21 |
+
<li><strong>Temporary</strong> - Time-limited users with custom prompt limits. Auto-disabled when limits are reached.</li>
|
| 22 |
+
</ul>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<form method="POST" action="{{ url_for('admin.create_user') }}" onsubmit="return validateUserForm()">
|
| 26 |
+
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
| 27 |
+
|
| 28 |
+
<div class="form-group">
|
| 29 |
+
<label for="type">User Type:</label>
|
| 30 |
+
<select id="type" name="type">
|
| 31 |
+
<option value="normal">Normal</option>
|
| 32 |
+
<option value="special">Special</option>
|
| 33 |
+
<option value="temporary">Temporary</option>
|
| 34 |
+
</select>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="form-group">
|
| 38 |
+
<label for="nickname">Nickname (Optional):</label>
|
| 39 |
+
<input type="text" id="nickname" name="nickname" placeholder="Enter a memorable name">
|
| 40 |
+
<small>A human-readable identifier for this user</small>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="form-group">
|
| 44 |
+
<label for="openai_limit">OpenAI Token Limit:</label>
|
| 45 |
+
<input type="number" id="openai_limit" name="openai_limit" min="0" placeholder="100000">
|
| 46 |
+
<small>Maximum tokens this user can consume for OpenAI models (leave empty for unlimited)</small>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="form-group">
|
| 50 |
+
<label for="anthropic_limit">Anthropic Token Limit:</label>
|
| 51 |
+
<input type="number" id="anthropic_limit" name="anthropic_limit" min="0" placeholder="50000">
|
| 52 |
+
<small>Maximum tokens this user can consume for Anthropic models (leave empty for unlimited)</small>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- Temporary User Options -->
|
| 56 |
+
<fieldset id="temporaryUserOptions" style="display: none;">
|
| 57 |
+
<legend>Temporary User Options</legend>
|
| 58 |
+
<div class="temp-user-grid">
|
| 59 |
+
<p class="full-width">
|
| 60 |
+
Temporary users will be disabled after the specified number of prompts.
|
| 61 |
+
These options apply only to new temporary users.
|
| 62 |
+
</p>
|
| 63 |
+
<div class="form-group">
|
| 64 |
+
<label for="prompt_limits">Maximum Prompts:</label>
|
| 65 |
+
<input type="number" id="prompt_limits" name="prompt_limits" min="1" value="10" placeholder="10">
|
| 66 |
+
<small>Number of prompts before user is automatically disabled</small>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="form-group">
|
| 69 |
+
<label for="max_ips">Maximum IPs:</label>
|
| 70 |
+
<input type="number" id="max_ips" name="max_ips" min="1" value="2" placeholder="2">
|
| 71 |
+
<small>Maximum number of IP addresses this user can use</small>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</fieldset>
|
| 75 |
+
|
| 76 |
+
<div class="form-actions">
|
| 77 |
+
<button type="submit" class="btn btn-primary">Create User</button>
|
| 78 |
+
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">Cancel</a>
|
| 79 |
+
</div>
|
| 80 |
+
</form>
|
| 81 |
+
|
| 82 |
+
{% if recent_users %}
|
| 83 |
+
<div class="recent-users">
|
| 84 |
+
<h3>Recent Users</h3>
|
| 85 |
+
<ul>
|
| 86 |
+
{% for user in recent_users %}
|
| 87 |
+
<li>
|
| 88 |
+
<a href="{{ url_for('admin.view_user', token=user.token) }}">
|
| 89 |
+
{{ user.token[:8] }}...
|
| 90 |
+
{% if user.nickname %}<span class="nickname">({{ user.nickname }})</span>{% endif %}
|
| 91 |
+
<span class="user-type badge-{{ user.type.value }}">{{ user.type.value }}</span>
|
| 92 |
+
</a>
|
| 93 |
+
</li>
|
| 94 |
+
{% endfor %}
|
| 95 |
+
</ul>
|
| 96 |
+
</div>
|
| 97 |
+
{% endif %}
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<style>
|
| 101 |
+
.create-user {
|
| 102 |
+
max-width: 700px;
|
| 103 |
+
margin: 0 auto;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.success-message {
|
| 107 |
+
background: #d4edda;
|
| 108 |
+
color: #155724;
|
| 109 |
+
border: 1px solid #c3e6cb;
|
| 110 |
+
border-radius: 8px;
|
| 111 |
+
padding: 20px;
|
| 112 |
+
margin-bottom: 30px;
|
| 113 |
+
text-align: center;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.success-message h3 {
|
| 117 |
+
margin-top: 0;
|
| 118 |
+
color: #155724;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.user-types-info {
|
| 122 |
+
background: #f8f9fa;
|
| 123 |
+
border: 1px solid #e9ecef;
|
| 124 |
+
border-radius: 8px;
|
| 125 |
+
padding: 20px;
|
| 126 |
+
margin-bottom: 30px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.user-types-info h3 {
|
| 130 |
+
margin-top: 0;
|
| 131 |
+
color: #495057;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.user-types-info ul {
|
| 135 |
+
margin-bottom: 0;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.user-types-info li {
|
| 139 |
+
margin-bottom: 8px;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.form-group {
|
| 143 |
+
margin-bottom: 20px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.form-group label {
|
| 147 |
+
display: block;
|
| 148 |
+
margin-bottom: 5px;
|
| 149 |
+
font-weight: bold;
|
| 150 |
+
color: #495057;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.form-group input,
|
| 154 |
+
.form-group select {
|
| 155 |
+
width: 100%;
|
| 156 |
+
padding: 10px;
|
| 157 |
+
border: 1px solid #ddd;
|
| 158 |
+
border-radius: 4px;
|
| 159 |
+
font-size: 14px;
|
| 160 |
+
box-sizing: border-box;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.form-group small {
|
| 164 |
+
display: block;
|
| 165 |
+
margin-top: 5px;
|
| 166 |
+
color: #666;
|
| 167 |
+
font-size: 12px;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* Temporary User Options */
|
| 171 |
+
#temporaryUserOptions {
|
| 172 |
+
border: 2px solid #ffc107;
|
| 173 |
+
border-radius: 8px;
|
| 174 |
+
padding: 20px;
|
| 175 |
+
margin: 20px 0;
|
| 176 |
+
background: #fff9c4;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
#temporaryUserOptions legend {
|
| 180 |
+
font-weight: bold;
|
| 181 |
+
color: #856404;
|
| 182 |
+
padding: 0 10px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.temp-user-grid {
|
| 186 |
+
display: grid;
|
| 187 |
+
grid-template-columns: 1fr 1fr;
|
| 188 |
+
gap: 15px;
|
| 189 |
+
margin-top: 10px;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.temp-user-grid .full-width {
|
| 193 |
+
grid-column: 1 / -1;
|
| 194 |
+
margin-bottom: 10px;
|
| 195 |
+
font-size: 14px;
|
| 196 |
+
color: #856404;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.form-actions {
|
| 200 |
+
display: flex;
|
| 201 |
+
gap: 10px;
|
| 202 |
+
margin-top: 30px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.btn {
|
| 206 |
+
padding: 12px 24px;
|
| 207 |
+
border: none;
|
| 208 |
+
border-radius: 4px;
|
| 209 |
+
cursor: pointer;
|
| 210 |
+
text-decoration: none;
|
| 211 |
+
display: inline-block;
|
| 212 |
+
text-align: center;
|
| 213 |
+
font-size: 14px;
|
| 214 |
+
font-weight: 500;
|
| 215 |
+
transition: all 0.3s ease;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.btn-primary {
|
| 219 |
+
background-color: #007bff;
|
| 220 |
+
color: white;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.btn-primary:hover {
|
| 224 |
+
background-color: #0056b3;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.btn-secondary {
|
| 228 |
+
background-color: #6c757d;
|
| 229 |
+
color: white;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.btn-secondary:hover {
|
| 233 |
+
background-color: #545b62;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* Recent Users */
|
| 237 |
+
.recent-users {
|
| 238 |
+
margin-top: 40px;
|
| 239 |
+
padding: 20px;
|
| 240 |
+
background: #f8f9fa;
|
| 241 |
+
border-radius: 8px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.recent-users h3 {
|
| 245 |
+
margin-top: 0;
|
| 246 |
+
color: #495057;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.recent-users ul {
|
| 250 |
+
list-style: none;
|
| 251 |
+
padding: 0;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.recent-users li {
|
| 255 |
+
margin-bottom: 8px;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.recent-users a {
|
| 259 |
+
text-decoration: none;
|
| 260 |
+
color: #007bff;
|
| 261 |
+
font-family: monospace;
|
| 262 |
+
display: flex;
|
| 263 |
+
align-items: center;
|
| 264 |
+
gap: 10px;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.recent-users a:hover {
|
| 268 |
+
text-decoration: underline;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.nickname {
|
| 272 |
+
color: #6c757d;
|
| 273 |
+
font-family: inherit;
|
| 274 |
+
font-style: italic;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.user-type {
|
| 278 |
+
font-size: 11px;
|
| 279 |
+
padding: 2px 6px;
|
| 280 |
+
border-radius: 3px;
|
| 281 |
+
font-weight: bold;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.badge-normal { background-color: #007bff; color: white; }
|
| 285 |
+
.badge-special { background-color: #28a745; color: white; }
|
| 286 |
+
.badge-temporary { background-color: #ffc107; color: black; }
|
| 287 |
+
|
| 288 |
+
/* Responsive design */
|
| 289 |
+
@media (max-width: 768px) {
|
| 290 |
+
.temp-user-grid {
|
| 291 |
+
grid-template-columns: 1fr;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.form-actions {
|
| 295 |
+
flex-direction: column;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.btn {
|
| 299 |
+
width: 100%;
|
| 300 |
+
text-align: center;
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
</style>
|
| 304 |
+
|
| 305 |
+
<script>
|
| 306 |
+
const typeInput = document.querySelector("select[name=type]");
|
| 307 |
+
const temporaryUserOptions = document.querySelector("#temporaryUserOptions");
|
| 308 |
+
const nicknameInput = document.querySelector("input[name=nickname]");
|
| 309 |
+
|
| 310 |
+
// Handle user type changes
|
| 311 |
+
typeInput.addEventListener("change", function() {
|
| 312 |
+
localStorage.setItem("admin__create-user__type", typeInput.value);
|
| 313 |
+
|
| 314 |
+
if (typeInput.value === "temporary") {
|
| 315 |
+
temporaryUserOptions.style.display = "block";
|
| 316 |
+
nicknameInput.required = false;
|
| 317 |
+
} else {
|
| 318 |
+
temporaryUserOptions.style.display = "none";
|
| 319 |
+
nicknameInput.required = false;
|
| 320 |
+
}
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
// Load saved preferences
|
| 324 |
+
function loadDefaults() {
|
| 325 |
+
const defaultType = localStorage.getItem("admin__create-user__type");
|
| 326 |
+
if (defaultType) {
|
| 327 |
+
typeInput.value = defaultType;
|
| 328 |
+
typeInput.dispatchEvent(new Event("change"));
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// Enhanced form validation
|
| 333 |
+
function validateUserForm() {
|
| 334 |
+
const nickname = document.getElementById('nickname').value.trim();
|
| 335 |
+
const type = document.getElementById('type').value;
|
| 336 |
+
const openaiLimit = document.getElementById('openai_limit').value;
|
| 337 |
+
const anthropicLimit = document.getElementById('anthropic_limit').value;
|
| 338 |
+
|
| 339 |
+
// Validate token limits
|
| 340 |
+
if (openaiLimit && (isNaN(openaiLimit) || parseInt(openaiLimit) < 0)) {
|
| 341 |
+
alert('OpenAI token limit must be a positive number');
|
| 342 |
+
return false;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
if (anthropicLimit && (isNaN(anthropicLimit) || parseInt(anthropicLimit) < 0)) {
|
| 346 |
+
alert('Anthropic token limit must be a positive number');
|
| 347 |
+
return false;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// Validate temporary user options
|
| 351 |
+
if (type === 'temporary') {
|
| 352 |
+
const promptLimits = document.getElementById('prompt_limits').value;
|
| 353 |
+
const maxIps = document.getElementById('max_ips').value;
|
| 354 |
+
|
| 355 |
+
if (!promptLimits || parseInt(promptLimits) < 1) {
|
| 356 |
+
alert('Prompt limits must be at least 1 for temporary users');
|
| 357 |
+
return false;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if (!maxIps || parseInt(maxIps) < 1) {
|
| 361 |
+
alert('Maximum IPs must be at least 1 for temporary users');
|
| 362 |
+
return false;
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
return true;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// Load defaults on page load
|
| 370 |
+
loadDefaults();
|
| 371 |
+
</script>
|
| 372 |
+
{% endblock %}
|
pages/admin/dashboard.html
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Admin Dashboard{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="dashboard-container">
|
| 7 |
+
<div class="dashboard-header">
|
| 8 |
+
<h1>{{ config.brand_emoji }} Admin Dashboard</h1>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
<!-- Authentication Configuration Section -->
|
| 12 |
+
<div class="config-section">
|
| 13 |
+
<h2>Authentication Configuration</h2>
|
| 14 |
+
<div class="config-content">
|
| 15 |
+
<div class="config-item">
|
| 16 |
+
<strong>Mode:</strong> {{ auth_config.mode }}
|
| 17 |
+
</div>
|
| 18 |
+
<div class="config-item">
|
| 19 |
+
<strong>User Token Mode:</strong> {{ 'Enabled' if auth_config.mode == 'user_token' else 'Disabled' }}
|
| 20 |
+
</div>
|
| 21 |
+
<div class="config-item">
|
| 22 |
+
<strong>Proxy Key Mode:</strong> {{ 'Enabled' if auth_config.mode == 'proxy_key' else 'Disabled' }}
|
| 23 |
+
</div>
|
| 24 |
+
<div class="config-item">
|
| 25 |
+
<strong>Rate Limiting:</strong> {{ 'Enabled' if auth_config.rate_limit_enabled else 'Disabled' }}
|
| 26 |
+
</div>
|
| 27 |
+
<div class="config-item">
|
| 28 |
+
<strong>Firebase Connected:</strong> {{ 'Yes' if firebase_connected else 'No' }}
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<!-- Statistics Grid -->
|
| 34 |
+
<div class="stats-grid">
|
| 35 |
+
<div class="stat-box">
|
| 36 |
+
<div class="stat-label">Total Users</div>
|
| 37 |
+
<div class="stat-value">{{ stats.total_users }}</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="stat-box">
|
| 40 |
+
<div class="stat-label">Active Users</div>
|
| 41 |
+
<div class="stat-value">{{ stats.active_users }}</div>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="stat-box">
|
| 44 |
+
<div class="stat-label">Disabled Users</div>
|
| 45 |
+
<div class="stat-value">{{ stats.disabled_users }}</div>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="stat-box">
|
| 48 |
+
<div class="stat-label">Total Requests</div>
|
| 49 |
+
<div class="stat-value">{{ stats.usage_stats.total_requests or 0 }}</div>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="stat-box">
|
| 52 |
+
<div class="stat-label">Total Tokens</div>
|
| 53 |
+
<div class="stat-value">{{ stats.usage_stats.total_tokens or 0 }}</div>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="stat-box">
|
| 56 |
+
<div class="stat-label">Total Cost</div>
|
| 57 |
+
<div class="stat-value">${{ "%.2f"|format(stats.usage_stats.total_cost or 0) }}</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Action Buttons -->
|
| 62 |
+
<div class="action-section">
|
| 63 |
+
<button class="action-btn primary" onclick="window.location.href='{{ url_for('admin.create_user') }}'">Create User</button>
|
| 64 |
+
<button class="action-btn secondary" onclick="refreshQuotas()">Refresh All Quotas</button>
|
| 65 |
+
<button class="action-btn secondary" onclick="refreshUsers()">Refresh Users</button>
|
| 66 |
+
<button class="action-btn warning" onclick="exportUsers()">Export Users</button>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<!-- Users Table -->
|
| 70 |
+
<div class="users-section">
|
| 71 |
+
<h2>Users</h2>
|
| 72 |
+
<div class="table-container">
|
| 73 |
+
<table class="users-table">
|
| 74 |
+
<thead>
|
| 75 |
+
<tr>
|
| 76 |
+
<th>API Token</th>
|
| 77 |
+
<th>Type</th>
|
| 78 |
+
<th>Nickname</th>
|
| 79 |
+
<th>Status</th>
|
| 80 |
+
<th>IPs</th>
|
| 81 |
+
<th>Requests</th>
|
| 82 |
+
<th>Token Usage</th>
|
| 83 |
+
<th>Actions</th>
|
| 84 |
+
</tr>
|
| 85 |
+
</thead>
|
| 86 |
+
<tbody>
|
| 87 |
+
{% if stats.total_users == 0 %}
|
| 88 |
+
<tr>
|
| 89 |
+
<td colspan="8" style="text-align: center; color: #666; padding: 20px;">
|
| 90 |
+
No users found. <a href="{{ url_for('admin.create_user') }}">Create your first user</a>
|
| 91 |
+
</td>
|
| 92 |
+
</tr>
|
| 93 |
+
{% else %}
|
| 94 |
+
{% for user_data in users_data %}
|
| 95 |
+
{% set user = user_data.user %}
|
| 96 |
+
{% set stats = user_data.stats %}
|
| 97 |
+
<tr class="{{ 'disabled' if user.disabled_at else '' }}">
|
| 98 |
+
<td>
|
| 99 |
+
<code>{{ user.token[:8] }}...</code>
|
| 100 |
+
</td>
|
| 101 |
+
<td>
|
| 102 |
+
<span class="badge badge-{{ user.type.value }}">{{ user.type.value }}</span>
|
| 103 |
+
</td>
|
| 104 |
+
<td>{{ user.nickname or 'N/A' }}</td>
|
| 105 |
+
<td>
|
| 106 |
+
{% if user.disabled_at %}
|
| 107 |
+
<span class="status-disabled">Disabled</span>
|
| 108 |
+
{% else %}
|
| 109 |
+
<span class="status-active">Active</span>
|
| 110 |
+
{% endif %}
|
| 111 |
+
</td>
|
| 112 |
+
<td>{{ stats.ip_count }}</td>
|
| 113 |
+
<td>{{ stats.total_requests }}</td>
|
| 114 |
+
<td>{{ stats.total_tokens }}</td>
|
| 115 |
+
<td>
|
| 116 |
+
<div class="action-buttons">
|
| 117 |
+
<a href="{{ url_for('admin.view_user', token=user.token) }}" class="btn btn-sm btn-info">View</a>
|
| 118 |
+
<a href="{{ url_for('admin.edit_user', token=user.token) }}" class="btn btn-sm btn-secondary">Edit</a>
|
| 119 |
+
{% if user.disabled_at %}
|
| 120 |
+
<button class="btn btn-sm btn-success" onclick="enableUser('{{ user.token }}')">Enable</button>
|
| 121 |
+
{% else %}
|
| 122 |
+
<button class="btn btn-sm btn-warning" onclick="disableUser('{{ user.token }}')">Disable</button>
|
| 123 |
+
{% endif %}
|
| 124 |
+
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{ user.token }}')">Delete</button>
|
| 125 |
+
</div>
|
| 126 |
+
</td>
|
| 127 |
+
</tr>
|
| 128 |
+
{% endfor %}
|
| 129 |
+
{% if users_data|length < stats.total_users %}
|
| 130 |
+
<tr>
|
| 131 |
+
<td colspan="8" style="text-align: center; color: #666; padding: 20px;">
|
| 132 |
+
<a href="{{ url_for('admin.list_users') }}">View all {{ stats.total_users }} users β</a>
|
| 133 |
+
</td>
|
| 134 |
+
</tr>
|
| 135 |
+
{% endif %}
|
| 136 |
+
{% endif %}
|
| 137 |
+
</tbody>
|
| 138 |
+
</table>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<style>
|
| 144 |
+
.dashboard-container {
|
| 145 |
+
max-width: 1400px;
|
| 146 |
+
width: 100%;
|
| 147 |
+
margin: 0 auto;
|
| 148 |
+
padding: 0;
|
| 149 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 150 |
+
display: flex;
|
| 151 |
+
flex-direction: column;
|
| 152 |
+
align-items: center;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.dashboard-header {
|
| 156 |
+
text-align: center;
|
| 157 |
+
margin-bottom: 30px;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.dashboard-header h1 {
|
| 161 |
+
color: #333;
|
| 162 |
+
font-size: 2em;
|
| 163 |
+
margin: 0;
|
| 164 |
+
font-weight: 500;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* Authentication Configuration Section */
|
| 168 |
+
.config-section {
|
| 169 |
+
background: #f8f9fa;
|
| 170 |
+
border: 1px solid #e9ecef;
|
| 171 |
+
border-radius: 8px;
|
| 172 |
+
padding: 20px;
|
| 173 |
+
margin-bottom: 25px;
|
| 174 |
+
width: 100%;
|
| 175 |
+
max-width: 1200px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.config-section h2 {
|
| 179 |
+
color: #495057;
|
| 180 |
+
font-size: 1.2em;
|
| 181 |
+
margin: 0 0 15px 0;
|
| 182 |
+
font-weight: 600;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.config-content {
|
| 186 |
+
display: grid;
|
| 187 |
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
| 188 |
+
gap: 10px;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.config-item {
|
| 192 |
+
font-size: 0.9em;
|
| 193 |
+
color: #495057;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.config-item strong {
|
| 197 |
+
color: #212529;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Statistics Grid */
|
| 201 |
+
.stats-grid {
|
| 202 |
+
display: grid;
|
| 203 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 204 |
+
gap: 15px;
|
| 205 |
+
margin-bottom: 25px;
|
| 206 |
+
border: 2px solid #dc3545;
|
| 207 |
+
border-radius: 8px;
|
| 208 |
+
padding: 20px;
|
| 209 |
+
background: white;
|
| 210 |
+
width: 100%;
|
| 211 |
+
max-width: 1200px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.stat-box {
|
| 215 |
+
text-align: center;
|
| 216 |
+
padding: 15px;
|
| 217 |
+
background: #f8f9fa;
|
| 218 |
+
border-radius: 6px;
|
| 219 |
+
border: 1px solid #e9ecef;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.stat-label {
|
| 223 |
+
font-size: 0.9em;
|
| 224 |
+
color: #6c757d;
|
| 225 |
+
margin-bottom: 8px;
|
| 226 |
+
font-weight: 500;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.stat-value {
|
| 230 |
+
font-size: 1.8em;
|
| 231 |
+
font-weight: bold;
|
| 232 |
+
color: #007bff;
|
| 233 |
+
margin: 0;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* Action Buttons */
|
| 237 |
+
.action-section {
|
| 238 |
+
display: flex;
|
| 239 |
+
gap: 10px;
|
| 240 |
+
margin-bottom: 25px;
|
| 241 |
+
flex-wrap: wrap;
|
| 242 |
+
width: 100%;
|
| 243 |
+
max-width: 1200px;
|
| 244 |
+
justify-content: center;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.action-btn {
|
| 248 |
+
padding: 8px 16px;
|
| 249 |
+
border: none;
|
| 250 |
+
border-radius: 4px;
|
| 251 |
+
font-size: 0.9em;
|
| 252 |
+
cursor: pointer;
|
| 253 |
+
transition: background-color 0.2s;
|
| 254 |
+
text-decoration: none;
|
| 255 |
+
display: inline-block;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.action-btn.primary {
|
| 259 |
+
background-color: #007bff;
|
| 260 |
+
color: white;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.action-btn.primary:hover {
|
| 264 |
+
background-color: #0056b3;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.action-btn.secondary {
|
| 268 |
+
background-color: #6c757d;
|
| 269 |
+
color: white;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.action-btn.secondary:hover {
|
| 273 |
+
background-color: #545b62;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.action-btn.warning {
|
| 277 |
+
background-color: #ffc107;
|
| 278 |
+
color: #212529;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.action-btn.warning:hover {
|
| 282 |
+
background-color: #e0a800;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
/* Users Section */
|
| 286 |
+
.users-section {
|
| 287 |
+
background: white;
|
| 288 |
+
border: 1px solid #e9ecef;
|
| 289 |
+
border-radius: 8px;
|
| 290 |
+
overflow: hidden;
|
| 291 |
+
width: 100%;
|
| 292 |
+
max-width: 1200px;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.users-section h2 {
|
| 296 |
+
background: #f8f9fa;
|
| 297 |
+
padding: 15px 20px;
|
| 298 |
+
margin: 0;
|
| 299 |
+
font-size: 1.1em;
|
| 300 |
+
font-weight: 600;
|
| 301 |
+
color: #495057;
|
| 302 |
+
border-bottom: 1px solid #e9ecef;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.table-container {
|
| 306 |
+
overflow-x: auto;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.users-table {
|
| 310 |
+
width: 100%;
|
| 311 |
+
border-collapse: collapse;
|
| 312 |
+
font-size: 0.9em;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.users-table th {
|
| 316 |
+
background: #f8f9fa;
|
| 317 |
+
padding: 12px 15px;
|
| 318 |
+
text-align: left;
|
| 319 |
+
font-weight: 600;
|
| 320 |
+
color: #495057;
|
| 321 |
+
border-bottom: 1px solid #e9ecef;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.users-table td {
|
| 325 |
+
padding: 12px 15px;
|
| 326 |
+
border-bottom: 1px solid #f8f9fa;
|
| 327 |
+
color: #495057;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.users-table tr:hover {
|
| 331 |
+
background-color: #f8f9fa;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.users-table a {
|
| 335 |
+
color: #007bff;
|
| 336 |
+
text-decoration: none;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.users-table a:hover {
|
| 340 |
+
text-decoration: underline;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
/* User badges and status */
|
| 344 |
+
.badge {
|
| 345 |
+
padding: 3px 8px;
|
| 346 |
+
border-radius: 3px;
|
| 347 |
+
font-size: 0.8em;
|
| 348 |
+
font-weight: bold;
|
| 349 |
+
}
|
| 350 |
+
.badge-normal { background-color: #007bff; color: white; }
|
| 351 |
+
.badge-special { background-color: #28a745; color: white; }
|
| 352 |
+
.badge-temporary { background-color: #ffc107; color: black; }
|
| 353 |
+
|
| 354 |
+
.status-active {
|
| 355 |
+
color: #28a745;
|
| 356 |
+
font-weight: bold;
|
| 357 |
+
}
|
| 358 |
+
.status-disabled {
|
| 359 |
+
color: #dc3545;
|
| 360 |
+
font-weight: bold;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/* Action buttons */
|
| 364 |
+
.action-buttons {
|
| 365 |
+
display: flex;
|
| 366 |
+
gap: 5px;
|
| 367 |
+
flex-wrap: wrap;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.btn {
|
| 371 |
+
padding: 6px 12px;
|
| 372 |
+
border: none;
|
| 373 |
+
border-radius: 4px;
|
| 374 |
+
font-size: 0.9em;
|
| 375 |
+
cursor: pointer;
|
| 376 |
+
transition: background-color 0.2s;
|
| 377 |
+
text-decoration: none;
|
| 378 |
+
display: inline-block;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.btn-sm {
|
| 382 |
+
padding: 4px 8px;
|
| 383 |
+
font-size: 0.8em;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.btn-info { background-color: #17a2b8; color: white; }
|
| 387 |
+
.btn-info:hover { background-color: #138496; }
|
| 388 |
+
.btn-secondary { background-color: #6c757d; color: white; }
|
| 389 |
+
.btn-secondary:hover { background-color: #545b62; }
|
| 390 |
+
.btn-warning { background-color: #ffc107; color: black; }
|
| 391 |
+
.btn-warning:hover { background-color: #e0a800; }
|
| 392 |
+
.btn-danger { background-color: #dc3545; color: white; }
|
| 393 |
+
.btn-danger:hover { background-color: #c82333; }
|
| 394 |
+
.btn-success { background-color: #28a745; color: white; }
|
| 395 |
+
.btn-success:hover { background-color: #218838; }
|
| 396 |
+
|
| 397 |
+
/* Disabled row styling */
|
| 398 |
+
.users-table tr.disabled {
|
| 399 |
+
opacity: 0.6;
|
| 400 |
+
background-color: #f8f9fa;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
/* Responsive Design */
|
| 404 |
+
@media (max-width: 768px) {
|
| 405 |
+
.dashboard-container {
|
| 406 |
+
padding: 15px;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.config-content {
|
| 410 |
+
grid-template-columns: 1fr;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.stats-grid {
|
| 414 |
+
grid-template-columns: repeat(2, 1fr);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.action-section {
|
| 418 |
+
flex-direction: column;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.action-btn {
|
| 422 |
+
width: 100%;
|
| 423 |
+
text-align: center;
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
@media (max-width: 480px) {
|
| 428 |
+
.stats-grid {
|
| 429 |
+
grid-template-columns: 1fr;
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
</style>
|
| 433 |
+
|
| 434 |
+
<script>
|
| 435 |
+
function refreshQuotas() {
|
| 436 |
+
alert('Refresh All Quotas functionality - to be implemented');
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
function refreshUsers() {
|
| 440 |
+
window.location.reload();
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
function exportUsers() {
|
| 444 |
+
alert('Export Users functionality - to be implemented');
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
// User management functions
|
| 448 |
+
function enableUser(token) {
|
| 449 |
+
if (confirm('Are you sure you want to enable this user?')) {
|
| 450 |
+
$.post(`/admin/users/${token}/enable`, {})
|
| 451 |
+
.done(function(data) {
|
| 452 |
+
if (data.success) {
|
| 453 |
+
location.reload();
|
| 454 |
+
} else {
|
| 455 |
+
alert('Error: ' + data.error);
|
| 456 |
+
}
|
| 457 |
+
})
|
| 458 |
+
.fail(function() {
|
| 459 |
+
alert('Error: Failed to enable user');
|
| 460 |
+
});
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
function disableUser(token) {
|
| 465 |
+
const reason = prompt('Reason for disabling user:');
|
| 466 |
+
if (reason) {
|
| 467 |
+
$.post(`/admin/users/${token}/disable`, {
|
| 468 |
+
reason: reason
|
| 469 |
+
})
|
| 470 |
+
.done(function(data) {
|
| 471 |
+
if (data.success) {
|
| 472 |
+
location.reload();
|
| 473 |
+
} else {
|
| 474 |
+
alert('Error: ' + data.error);
|
| 475 |
+
}
|
| 476 |
+
})
|
| 477 |
+
.fail(function() {
|
| 478 |
+
alert('Error: Failed to disable user');
|
| 479 |
+
});
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
function deleteUser(token) {
|
| 484 |
+
if (confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
| 485 |
+
$.ajax({
|
| 486 |
+
url: `/admin/users/${token}`,
|
| 487 |
+
type: 'DELETE'
|
| 488 |
+
})
|
| 489 |
+
.done(function(data) {
|
| 490 |
+
if (data.success) {
|
| 491 |
+
location.reload();
|
| 492 |
+
} else {
|
| 493 |
+
alert('Error: ' + data.error);
|
| 494 |
+
}
|
| 495 |
+
})
|
| 496 |
+
.fail(function() {
|
| 497 |
+
alert('Error: Failed to delete user');
|
| 498 |
+
});
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
</script>
|
| 502 |
+
{% endblock %}
|
pages/admin/edit_user.html
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Edit User{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="edit-user">
|
| 7 |
+
<h1>Edit User</h1>
|
| 8 |
+
|
| 9 |
+
<div class="user-summary">
|
| 10 |
+
<p><strong>Token:</strong> <code>{{ user.token }}</code></p>
|
| 11 |
+
<p><strong>Created:</strong> {{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
| 12 |
+
<p><strong>Status:</strong>
|
| 13 |
+
{% if user.disabled_at %}
|
| 14 |
+
<span class="status-disabled">Disabled</span>
|
| 15 |
+
{% else %}
|
| 16 |
+
<span class="status-active">Active</span>
|
| 17 |
+
{% endif %}
|
| 18 |
+
</p>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<form method="POST" action="{{ url_for('admin.edit_user', token=user.token) }}" onsubmit="return validateUserForm()">
|
| 22 |
+
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
| 23 |
+
|
| 24 |
+
<div class="form-group">
|
| 25 |
+
<label for="nickname">Nickname:</label>
|
| 26 |
+
<input type="text" id="nickname" name="nickname" value="{{ user.nickname or '' }}" required>
|
| 27 |
+
<small>A human-readable identifier for this user</small>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div class="form-group">
|
| 31 |
+
<label for="type">User Type:</label>
|
| 32 |
+
<select id="type" name="type">
|
| 33 |
+
<option value="normal" {{ 'selected' if user.type == 'normal' }}>Normal</option>
|
| 34 |
+
<option value="special" {{ 'selected' if user.type == 'special' }}>Special</option>
|
| 35 |
+
<option value="temporary" {{ 'selected' if user.type == 'temporary' }}>Temporary</option>
|
| 36 |
+
</select>
|
| 37 |
+
<small>
|
| 38 |
+
<strong>Normal:</strong> Standard user with default quotas<br>
|
| 39 |
+
<strong>Special:</strong> Premium user with higher limits and no rate limiting<br>
|
| 40 |
+
<strong>Temporary:</strong> Time-limited user (expires after 24 hours)
|
| 41 |
+
</small>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="form-group">
|
| 45 |
+
<label for="openai_limit">OpenAI Token Limit:</label>
|
| 46 |
+
<input type="number" id="openai_limit" name="openai_limit" min="0"
|
| 47 |
+
value="{{ user.token_limits.get('openai', '') }}" placeholder="Leave empty for unlimited">
|
| 48 |
+
<small>Maximum tokens this user can consume for OpenAI models</small>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="form-group">
|
| 52 |
+
<label for="anthropic_limit">Anthropic Token Limit:</label>
|
| 53 |
+
<input type="number" id="anthropic_limit" name="anthropic_limit" min="0"
|
| 54 |
+
value="{{ user.token_limits.get('anthropic', '') }}" placeholder="Leave empty for unlimited">
|
| 55 |
+
<small>Maximum tokens this user can consume for Anthropic models</small>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div class="current-usage">
|
| 59 |
+
<h3>Current Usage</h3>
|
| 60 |
+
<table class="usage-table">
|
| 61 |
+
<thead>
|
| 62 |
+
<tr>
|
| 63 |
+
<th>Model Family</th>
|
| 64 |
+
<th>Input Tokens</th>
|
| 65 |
+
<th>Output Tokens</th>
|
| 66 |
+
<th>Total Tokens</th>
|
| 67 |
+
<th>Current Limit</th>
|
| 68 |
+
</tr>
|
| 69 |
+
</thead>
|
| 70 |
+
<tbody>
|
| 71 |
+
{% for family, count in user.token_counts.items() %}
|
| 72 |
+
<tr>
|
| 73 |
+
<td>{{ family }}</td>
|
| 74 |
+
<td>{{ count.input }}</td>
|
| 75 |
+
<td>{{ count.output }}</td>
|
| 76 |
+
<td>{{ count.total }}</td>
|
| 77 |
+
<td>{{ user.token_limits.get(family, 'Unlimited') }}</td>
|
| 78 |
+
</tr>
|
| 79 |
+
{% endfor %}
|
| 80 |
+
</tbody>
|
| 81 |
+
</table>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="form-actions">
|
| 85 |
+
<button type="submit" class="btn btn-primary">Save Changes</button>
|
| 86 |
+
<a href="{{ url_for('admin.view_user', token=user.token) }}" class="btn btn-secondary">Cancel</a>
|
| 87 |
+
<button type="button" class="btn btn-warning" onclick="resetUserUsage()">Reset Usage</button>
|
| 88 |
+
</div>
|
| 89 |
+
</form>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<style>
|
| 93 |
+
.edit-user {
|
| 94 |
+
max-width: 800px;
|
| 95 |
+
margin: 0 auto;
|
| 96 |
+
}
|
| 97 |
+
.user-summary {
|
| 98 |
+
background: #f8f9fa;
|
| 99 |
+
padding: 15px;
|
| 100 |
+
border-radius: 5px;
|
| 101 |
+
margin-bottom: 30px;
|
| 102 |
+
}
|
| 103 |
+
.user-summary p {
|
| 104 |
+
margin: 5px 0;
|
| 105 |
+
}
|
| 106 |
+
.form-group {
|
| 107 |
+
margin-bottom: 20px;
|
| 108 |
+
}
|
| 109 |
+
.form-group label {
|
| 110 |
+
display: block;
|
| 111 |
+
margin-bottom: 5px;
|
| 112 |
+
font-weight: bold;
|
| 113 |
+
}
|
| 114 |
+
.form-group input,
|
| 115 |
+
.form-group select {
|
| 116 |
+
width: 100%;
|
| 117 |
+
padding: 8px;
|
| 118 |
+
border: 1px solid #ddd;
|
| 119 |
+
border-radius: 3px;
|
| 120 |
+
font-size: 14px;
|
| 121 |
+
}
|
| 122 |
+
.form-group small {
|
| 123 |
+
display: block;
|
| 124 |
+
margin-top: 5px;
|
| 125 |
+
color: #666;
|
| 126 |
+
font-size: 12px;
|
| 127 |
+
}
|
| 128 |
+
.current-usage {
|
| 129 |
+
margin: 30px 0;
|
| 130 |
+
padding: 20px;
|
| 131 |
+
background: #f8f9fa;
|
| 132 |
+
border-radius: 5px;
|
| 133 |
+
}
|
| 134 |
+
.current-usage h3 {
|
| 135 |
+
margin-top: 0;
|
| 136 |
+
margin-bottom: 15px;
|
| 137 |
+
}
|
| 138 |
+
.usage-table {
|
| 139 |
+
width: 100%;
|
| 140 |
+
border-collapse: collapse;
|
| 141 |
+
}
|
| 142 |
+
.usage-table th,
|
| 143 |
+
.usage-table td {
|
| 144 |
+
padding: 8px;
|
| 145 |
+
border: 1px solid #dee2e6;
|
| 146 |
+
text-align: left;
|
| 147 |
+
}
|
| 148 |
+
.usage-table th {
|
| 149 |
+
background-color: #e9ecef;
|
| 150 |
+
}
|
| 151 |
+
.form-actions {
|
| 152 |
+
display: flex;
|
| 153 |
+
gap: 10px;
|
| 154 |
+
margin-top: 30px;
|
| 155 |
+
}
|
| 156 |
+
.btn {
|
| 157 |
+
padding: 10px 20px;
|
| 158 |
+
border: none;
|
| 159 |
+
border-radius: 3px;
|
| 160 |
+
cursor: pointer;
|
| 161 |
+
text-decoration: none;
|
| 162 |
+
display: inline-block;
|
| 163 |
+
text-align: center;
|
| 164 |
+
}
|
| 165 |
+
.btn-primary {
|
| 166 |
+
background-color: #007bff;
|
| 167 |
+
color: white;
|
| 168 |
+
}
|
| 169 |
+
.btn-secondary {
|
| 170 |
+
background-color: #6c757d;
|
| 171 |
+
color: white;
|
| 172 |
+
}
|
| 173 |
+
.btn-warning {
|
| 174 |
+
background-color: #ffc107;
|
| 175 |
+
color: black;
|
| 176 |
+
}
|
| 177 |
+
.btn:hover {
|
| 178 |
+
opacity: 0.9;
|
| 179 |
+
}
|
| 180 |
+
.status-active { color: #28a745; }
|
| 181 |
+
.status-disabled { color: #dc3545; }
|
| 182 |
+
</style>
|
| 183 |
+
|
| 184 |
+
<script>
|
| 185 |
+
function resetUserUsage() {
|
| 186 |
+
if (confirm('Are you sure you want to reset this user\'s token usage? This action cannot be undone.')) {
|
| 187 |
+
$.post(`/admin/users/{{ user.token }}/reset-usage`, {})
|
| 188 |
+
.done(function(data) {
|
| 189 |
+
if (data.success) {
|
| 190 |
+
alert('User usage reset successfully');
|
| 191 |
+
location.reload();
|
| 192 |
+
} else {
|
| 193 |
+
alert('Error: ' + data.error);
|
| 194 |
+
}
|
| 195 |
+
})
|
| 196 |
+
.fail(function() {
|
| 197 |
+
alert('Error: Failed to reset user usage');
|
| 198 |
+
});
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
</script>
|
| 202 |
+
{% endblock %}
|
pages/admin/key_manager.html
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Key Manager{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="key-manager">
|
| 7 |
+
<h1>API Key Management</h1>
|
| 8 |
+
|
| 9 |
+
<div class="key-tabs">
|
| 10 |
+
<button class="tab-button active" onclick="showTab('openai')">OpenAI</button>
|
| 11 |
+
<button class="tab-button" onclick="showTab('anthropic')">Anthropic</button>
|
| 12 |
+
<button class="tab-button" onclick="showTab('google')">Google</button>
|
| 13 |
+
<button class="tab-button" onclick="showTab('mistral')">Mistral</button>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="tab-content">
|
| 17 |
+
<div id="openai-tab" class="tab-panel active">
|
| 18 |
+
<h2>OpenAI API Keys</h2>
|
| 19 |
+
<div class="key-section">
|
| 20 |
+
<div class="add-key">
|
| 21 |
+
<h3>Add New OpenAI Key</h3>
|
| 22 |
+
<form id="add-openai-key" onsubmit="addKey(event, 'openai')">
|
| 23 |
+
<input type="text" id="openai-key" placeholder="sk-..." required>
|
| 24 |
+
<button type="submit">Add Key</button>
|
| 25 |
+
</form>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="keys-list" id="openai-keys">
|
| 29 |
+
<!-- Keys will be loaded here -->
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div id="anthropic-tab" class="tab-panel">
|
| 35 |
+
<h2>Anthropic API Keys</h2>
|
| 36 |
+
<div class="key-section">
|
| 37 |
+
<div class="add-key">
|
| 38 |
+
<h3>Add New Anthropic Key</h3>
|
| 39 |
+
<form id="add-anthropic-key" onsubmit="addKey(event, 'anthropic')">
|
| 40 |
+
<input type="text" id="anthropic-key" placeholder="sk-ant-..." required>
|
| 41 |
+
<button type="submit">Add Key</button>
|
| 42 |
+
</form>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div class="keys-list" id="anthropic-keys">
|
| 46 |
+
<!-- Keys will be loaded here -->
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div id="google-tab" class="tab-panel">
|
| 52 |
+
<h2>Google AI API Keys</h2>
|
| 53 |
+
<div class="key-section">
|
| 54 |
+
<div class="add-key">
|
| 55 |
+
<h3>Add New Google AI Key</h3>
|
| 56 |
+
<form id="add-google-key" onsubmit="addKey(event, 'google')">
|
| 57 |
+
<input type="text" id="google-key" placeholder="AI..." required>
|
| 58 |
+
<button type="submit">Add Key</button>
|
| 59 |
+
</form>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="keys-list" id="google-keys">
|
| 63 |
+
<!-- Keys will be loaded here -->
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div id="mistral-tab" class="tab-panel">
|
| 69 |
+
<h2>Mistral API Keys</h2>
|
| 70 |
+
<div class="key-section">
|
| 71 |
+
<div class="add-key">
|
| 72 |
+
<h3>Add New Mistral Key</h3>
|
| 73 |
+
<form id="add-mistral-key" onsubmit="addKey(event, 'mistral')">
|
| 74 |
+
<input type="text" id="mistral-key" placeholder="..." required>
|
| 75 |
+
<button type="submit">Add Key</button>
|
| 76 |
+
</form>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div class="keys-list" id="mistral-keys">
|
| 80 |
+
<!-- Keys will be loaded here -->
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="key-stats">
|
| 87 |
+
<h2>Key Statistics</h2>
|
| 88 |
+
<div class="stats-grid">
|
| 89 |
+
<div class="stat-card">
|
| 90 |
+
<h3>Total Keys</h3>
|
| 91 |
+
<p class="stat-number" id="total-keys">-</p>
|
| 92 |
+
</div>
|
| 93 |
+
<div class="stat-card">
|
| 94 |
+
<h3>Active Keys</h3>
|
| 95 |
+
<p class="stat-number" id="active-keys">-</p>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="stat-card">
|
| 98 |
+
<h3>Failed Keys</h3>
|
| 99 |
+
<p class="stat-number" id="failed-keys">-</p>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="stat-card">
|
| 102 |
+
<h3>Total Requests</h3>
|
| 103 |
+
<p class="stat-number" id="total-requests">-</p>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<style>
|
| 110 |
+
.key-tabs {
|
| 111 |
+
display: flex;
|
| 112 |
+
border-bottom: 1px solid #ddd;
|
| 113 |
+
margin-bottom: 20px;
|
| 114 |
+
}
|
| 115 |
+
.tab-button {
|
| 116 |
+
padding: 10px 20px;
|
| 117 |
+
border: none;
|
| 118 |
+
background: none;
|
| 119 |
+
cursor: pointer;
|
| 120 |
+
border-bottom: 2px solid transparent;
|
| 121 |
+
}
|
| 122 |
+
.tab-button.active {
|
| 123 |
+
border-bottom-color: #007bff;
|
| 124 |
+
color: #007bff;
|
| 125 |
+
}
|
| 126 |
+
.tab-panel {
|
| 127 |
+
display: none;
|
| 128 |
+
}
|
| 129 |
+
.tab-panel.active {
|
| 130 |
+
display: block;
|
| 131 |
+
}
|
| 132 |
+
.key-section {
|
| 133 |
+
margin-bottom: 30px;
|
| 134 |
+
}
|
| 135 |
+
.add-key {
|
| 136 |
+
background: #f8f9fa;
|
| 137 |
+
padding: 20px;
|
| 138 |
+
border-radius: 5px;
|
| 139 |
+
margin-bottom: 20px;
|
| 140 |
+
}
|
| 141 |
+
.add-key h3 {
|
| 142 |
+
margin-top: 0;
|
| 143 |
+
margin-bottom: 15px;
|
| 144 |
+
}
|
| 145 |
+
.add-key form {
|
| 146 |
+
display: flex;
|
| 147 |
+
gap: 10px;
|
| 148 |
+
}
|
| 149 |
+
.add-key input {
|
| 150 |
+
flex: 1;
|
| 151 |
+
padding: 8px;
|
| 152 |
+
border: 1px solid #ddd;
|
| 153 |
+
border-radius: 3px;
|
| 154 |
+
}
|
| 155 |
+
.add-key button {
|
| 156 |
+
padding: 8px 16px;
|
| 157 |
+
background-color: #007bff;
|
| 158 |
+
color: white;
|
| 159 |
+
border: none;
|
| 160 |
+
border-radius: 3px;
|
| 161 |
+
cursor: pointer;
|
| 162 |
+
}
|
| 163 |
+
.key-item {
|
| 164 |
+
display: flex;
|
| 165 |
+
justify-content: space-between;
|
| 166 |
+
align-items: center;
|
| 167 |
+
padding: 15px;
|
| 168 |
+
border: 1px solid #ddd;
|
| 169 |
+
border-radius: 5px;
|
| 170 |
+
margin-bottom: 10px;
|
| 171 |
+
}
|
| 172 |
+
.key-info {
|
| 173 |
+
flex: 1;
|
| 174 |
+
}
|
| 175 |
+
.key-hash {
|
| 176 |
+
font-family: monospace;
|
| 177 |
+
font-size: 0.9em;
|
| 178 |
+
color: #666;
|
| 179 |
+
}
|
| 180 |
+
.key-status {
|
| 181 |
+
margin-top: 5px;
|
| 182 |
+
}
|
| 183 |
+
.status-active { color: #28a745; }
|
| 184 |
+
.status-failed { color: #dc3545; }
|
| 185 |
+
.status-unknown { color: #6c757d; }
|
| 186 |
+
.key-actions {
|
| 187 |
+
display: flex;
|
| 188 |
+
gap: 10px;
|
| 189 |
+
}
|
| 190 |
+
.btn-test {
|
| 191 |
+
background-color: #17a2b8;
|
| 192 |
+
color: white;
|
| 193 |
+
}
|
| 194 |
+
.btn-remove {
|
| 195 |
+
background-color: #dc3545;
|
| 196 |
+
color: white;
|
| 197 |
+
}
|
| 198 |
+
.stats-grid {
|
| 199 |
+
display: grid;
|
| 200 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 201 |
+
gap: 20px;
|
| 202 |
+
margin-top: 20px;
|
| 203 |
+
}
|
| 204 |
+
.stat-card {
|
| 205 |
+
background: #f8f9fa;
|
| 206 |
+
padding: 20px;
|
| 207 |
+
border-radius: 5px;
|
| 208 |
+
text-align: center;
|
| 209 |
+
border: 1px solid #dee2e6;
|
| 210 |
+
}
|
| 211 |
+
.stat-card h3 {
|
| 212 |
+
margin: 0 0 10px 0;
|
| 213 |
+
color: #495057;
|
| 214 |
+
}
|
| 215 |
+
.stat-number {
|
| 216 |
+
font-size: 2em;
|
| 217 |
+
font-weight: bold;
|
| 218 |
+
color: #007bff;
|
| 219 |
+
margin: 0;
|
| 220 |
+
}
|
| 221 |
+
</style>
|
| 222 |
+
|
| 223 |
+
<script>
|
| 224 |
+
function showTab(service) {
|
| 225 |
+
// Hide all tab panels
|
| 226 |
+
document.querySelectorAll('.tab-panel').forEach(panel => {
|
| 227 |
+
panel.classList.remove('active');
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
// Remove active class from all buttons
|
| 231 |
+
document.querySelectorAll('.tab-button').forEach(button => {
|
| 232 |
+
button.classList.remove('active');
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// Show selected tab panel
|
| 236 |
+
document.getElementById(service + '-tab').classList.add('active');
|
| 237 |
+
|
| 238 |
+
// Add active class to clicked button
|
| 239 |
+
event.target.classList.add('active');
|
| 240 |
+
|
| 241 |
+
// Load keys for this service
|
| 242 |
+
loadKeys(service);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function loadKeys(service) {
|
| 246 |
+
// This would load keys from the backend
|
| 247 |
+
// For now, show placeholder
|
| 248 |
+
const container = document.getElementById(service + '-keys');
|
| 249 |
+
container.innerHTML = '<p><em>Key management functionality to be implemented</em></p>';
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function addKey(event, service) {
|
| 253 |
+
event.preventDefault();
|
| 254 |
+
const input = document.getElementById(service + '-key');
|
| 255 |
+
const key = input.value.trim();
|
| 256 |
+
|
| 257 |
+
if (!key) {
|
| 258 |
+
alert('Please enter a valid API key');
|
| 259 |
+
return;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// Add key via API
|
| 263 |
+
fetch('/admin/keys', {
|
| 264 |
+
method: 'POST',
|
| 265 |
+
headers: {
|
| 266 |
+
'Content-Type': 'application/json',
|
| 267 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 268 |
+
},
|
| 269 |
+
body: JSON.stringify({
|
| 270 |
+
service: service,
|
| 271 |
+
key: key
|
| 272 |
+
})
|
| 273 |
+
})
|
| 274 |
+
.then(response => response.json())
|
| 275 |
+
.then(data => {
|
| 276 |
+
if (data.success) {
|
| 277 |
+
input.value = '';
|
| 278 |
+
loadKeys(service);
|
| 279 |
+
updateStats();
|
| 280 |
+
} else {
|
| 281 |
+
alert('Error: ' + data.error);
|
| 282 |
+
}
|
| 283 |
+
})
|
| 284 |
+
.catch(error => {
|
| 285 |
+
console.error('Error adding key:', error);
|
| 286 |
+
alert('Error adding key');
|
| 287 |
+
});
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function removeKey(service, keyHash) {
|
| 291 |
+
if (confirm('Are you sure you want to remove this API key?')) {
|
| 292 |
+
fetch(`/admin/keys/${service}/${keyHash}`, {
|
| 293 |
+
method: 'DELETE',
|
| 294 |
+
headers: {
|
| 295 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 296 |
+
}
|
| 297 |
+
})
|
| 298 |
+
.then(response => response.json())
|
| 299 |
+
.then(data => {
|
| 300 |
+
if (data.success) {
|
| 301 |
+
loadKeys(service);
|
| 302 |
+
updateStats();
|
| 303 |
+
} else {
|
| 304 |
+
alert('Error: ' + data.error);
|
| 305 |
+
}
|
| 306 |
+
})
|
| 307 |
+
.catch(error => {
|
| 308 |
+
console.error('Error removing key:', error);
|
| 309 |
+
alert('Error removing key');
|
| 310 |
+
});
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
function testKey(service, keyHash) {
|
| 315 |
+
fetch(`/admin/keys/${service}/${keyHash}/test`, {
|
| 316 |
+
method: 'POST',
|
| 317 |
+
headers: {
|
| 318 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 319 |
+
}
|
| 320 |
+
})
|
| 321 |
+
.then(response => response.json())
|
| 322 |
+
.then(data => {
|
| 323 |
+
if (data.success) {
|
| 324 |
+
alert('Key test successful');
|
| 325 |
+
} else {
|
| 326 |
+
alert('Key test failed: ' + data.error);
|
| 327 |
+
}
|
| 328 |
+
loadKeys(service);
|
| 329 |
+
})
|
| 330 |
+
.catch(error => {
|
| 331 |
+
console.error('Error testing key:', error);
|
| 332 |
+
alert('Error testing key');
|
| 333 |
+
});
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
function updateStats() {
|
| 337 |
+
// Update key statistics
|
| 338 |
+
fetch('/admin/keys/stats')
|
| 339 |
+
.then(response => response.json())
|
| 340 |
+
.then(data => {
|
| 341 |
+
document.getElementById('total-keys').textContent = data.total || 0;
|
| 342 |
+
document.getElementById('active-keys').textContent = data.active || 0;
|
| 343 |
+
document.getElementById('failed-keys').textContent = data.failed || 0;
|
| 344 |
+
document.getElementById('total-requests').textContent = data.requests || 0;
|
| 345 |
+
})
|
| 346 |
+
.catch(error => {
|
| 347 |
+
console.error('Error updating stats:', error);
|
| 348 |
+
});
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// Load initial data
|
| 352 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 353 |
+
loadKeys('openai');
|
| 354 |
+
updateStats();
|
| 355 |
+
});
|
| 356 |
+
</script>
|
| 357 |
+
{% endblock %}
|
pages/admin/list_users.html
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Manage Users{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="user-management">
|
| 7 |
+
<div class="header-actions">
|
| 8 |
+
<h1>User Management</h1>
|
| 9 |
+
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">Create New User</a>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div class="filters">
|
| 13 |
+
<form method="GET" action="{{ url_for('admin.list_users') }}">
|
| 14 |
+
<div class="filter-row">
|
| 15 |
+
<input type="text" name="search" placeholder="Search users..." value="{{ request.args.get('search', '') }}">
|
| 16 |
+
<select name="type">
|
| 17 |
+
<option value="">All Types</option>
|
| 18 |
+
<option value="normal" {{ 'selected' if request.args.get('type') == 'normal' }}>Normal</option>
|
| 19 |
+
<option value="special" {{ 'selected' if request.args.get('type') == 'special' }}>Special</option>
|
| 20 |
+
<option value="temporary" {{ 'selected' if request.args.get('type') == 'temporary' }}>Temporary</option>
|
| 21 |
+
</select>
|
| 22 |
+
<select name="limit">
|
| 23 |
+
<option value="25" {{ 'selected' if request.args.get('limit') == '25' }}>25 per page</option>
|
| 24 |
+
<option value="50" {{ 'selected' if request.args.get('limit') == '50' }}>50 per page</option>
|
| 25 |
+
<option value="100" {{ 'selected' if request.args.get('limit') == '100' }}>100 per page</option>
|
| 26 |
+
</select>
|
| 27 |
+
<button type="submit">Filter</button>
|
| 28 |
+
</div>
|
| 29 |
+
</form>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div class="users-table">
|
| 33 |
+
<table>
|
| 34 |
+
<thead>
|
| 35 |
+
<tr>
|
| 36 |
+
<th>Token</th>
|
| 37 |
+
<th>Nickname</th>
|
| 38 |
+
<th>Type</th>
|
| 39 |
+
<th>Created</th>
|
| 40 |
+
<th>Status</th>
|
| 41 |
+
<th>Usage</th>
|
| 42 |
+
<th>Actions</th>
|
| 43 |
+
</tr>
|
| 44 |
+
</thead>
|
| 45 |
+
<tbody>
|
| 46 |
+
{% for user_data in users %}
|
| 47 |
+
{% set user = user_data.user %}
|
| 48 |
+
{% set stats = user_data.stats %}
|
| 49 |
+
<tr class="{{ 'disabled' if user.disabled_at else '' }}">
|
| 50 |
+
<td>
|
| 51 |
+
<code>{{ user.token[:8] }}...</code>
|
| 52 |
+
</td>
|
| 53 |
+
<td>{{ user.nickname or 'N/A' }}</td>
|
| 54 |
+
<td>
|
| 55 |
+
<span class="badge badge-{{ user.type.value }}">{{ user.type.value }}</span>
|
| 56 |
+
</td>
|
| 57 |
+
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
| 58 |
+
<td>
|
| 59 |
+
{% if user.disabled_at %}
|
| 60 |
+
<span class="status-disabled">Disabled</span>
|
| 61 |
+
{% else %}
|
| 62 |
+
<span class="status-active">Active</span>
|
| 63 |
+
{% endif %}
|
| 64 |
+
</td>
|
| 65 |
+
<td>
|
| 66 |
+
<div class="usage-info">
|
| 67 |
+
<div>{{ stats.total_tokens }} tokens</div>
|
| 68 |
+
<div>{{ stats.total_requests }} requests</div>
|
| 69 |
+
<div>{{ stats.ip_count }} IPs</div>
|
| 70 |
+
</div>
|
| 71 |
+
</td>
|
| 72 |
+
<td>
|
| 73 |
+
<div class="action-buttons">
|
| 74 |
+
<a href="{{ url_for('admin.view_user', token=user.token) }}" class="btn btn-sm btn-info">View</a>
|
| 75 |
+
<a href="{{ url_for('admin.edit_user', token=user.token) }}" class="btn btn-sm btn-secondary">Edit</a>
|
| 76 |
+
{% if user.disabled_at %}
|
| 77 |
+
<button class="btn btn-sm btn-success" onclick="enableUser('{{ user.token }}')">Enable</button>
|
| 78 |
+
{% else %}
|
| 79 |
+
<button class="btn btn-sm btn-warning" onclick="disableUser('{{ user.token }}')">Disable</button>
|
| 80 |
+
{% endif %}
|
| 81 |
+
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{ user.token }}')">Delete</button>
|
| 82 |
+
</div>
|
| 83 |
+
</td>
|
| 84 |
+
</tr>
|
| 85 |
+
{% endfor %}
|
| 86 |
+
</tbody>
|
| 87 |
+
</table>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div class="pagination">
|
| 91 |
+
{% if pagination.page > 1 %}
|
| 92 |
+
<a href="{{ url_for('admin.list_users', page=pagination.page-1, **request.args) }}" class="btn btn-sm">Previous</a>
|
| 93 |
+
{% endif %}
|
| 94 |
+
|
| 95 |
+
<span class="page-info">
|
| 96 |
+
Page {{ pagination.page }} of {{ (pagination.total + pagination.limit - 1) // pagination.limit }}
|
| 97 |
+
({{ pagination.total }} total users)
|
| 98 |
+
</span>
|
| 99 |
+
|
| 100 |
+
{% if pagination.has_next %}
|
| 101 |
+
<a href="{{ url_for('admin.list_users', page=pagination.page+1, **request.args) }}" class="btn btn-sm">Next</a>
|
| 102 |
+
{% endif %}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<style>
|
| 107 |
+
.header-actions {
|
| 108 |
+
display: flex;
|
| 109 |
+
justify-content: space-between;
|
| 110 |
+
align-items: center;
|
| 111 |
+
margin-bottom: 20px;
|
| 112 |
+
}
|
| 113 |
+
.filters {
|
| 114 |
+
margin-bottom: 20px;
|
| 115 |
+
padding: 15px;
|
| 116 |
+
background: #f8f9fa;
|
| 117 |
+
border-radius: 5px;
|
| 118 |
+
}
|
| 119 |
+
.filter-row {
|
| 120 |
+
display: flex;
|
| 121 |
+
gap: 10px;
|
| 122 |
+
align-items: center;
|
| 123 |
+
}
|
| 124 |
+
.filter-row input, .filter-row select {
|
| 125 |
+
padding: 5px;
|
| 126 |
+
border: 1px solid #ddd;
|
| 127 |
+
border-radius: 3px;
|
| 128 |
+
}
|
| 129 |
+
.users-table table {
|
| 130 |
+
width: 100%;
|
| 131 |
+
border-collapse: collapse;
|
| 132 |
+
}
|
| 133 |
+
.users-table th, .users-table td {
|
| 134 |
+
padding: 10px;
|
| 135 |
+
border: 1px solid #dee2e6;
|
| 136 |
+
text-align: left;
|
| 137 |
+
}
|
| 138 |
+
.users-table th {
|
| 139 |
+
background-color: #f8f9fa;
|
| 140 |
+
}
|
| 141 |
+
.users-table tr.disabled {
|
| 142 |
+
opacity: 0.6;
|
| 143 |
+
background-color: #f8f8f8;
|
| 144 |
+
}
|
| 145 |
+
.badge {
|
| 146 |
+
padding: 3px 8px;
|
| 147 |
+
border-radius: 3px;
|
| 148 |
+
font-size: 0.8em;
|
| 149 |
+
}
|
| 150 |
+
.badge-normal { background-color: #007bff; color: white; }
|
| 151 |
+
.badge-special { background-color: #28a745; color: white; }
|
| 152 |
+
.badge-temporary { background-color: #ffc107; color: black; }
|
| 153 |
+
.status-active { color: #28a745; }
|
| 154 |
+
.status-disabled { color: #dc3545; }
|
| 155 |
+
.usage-info {
|
| 156 |
+
font-size: 0.9em;
|
| 157 |
+
}
|
| 158 |
+
.action-buttons {
|
| 159 |
+
display: flex;
|
| 160 |
+
gap: 5px;
|
| 161 |
+
flex-wrap: wrap;
|
| 162 |
+
}
|
| 163 |
+
.btn-sm {
|
| 164 |
+
padding: 5px 10px;
|
| 165 |
+
font-size: 0.8em;
|
| 166 |
+
}
|
| 167 |
+
.btn-info { background-color: #17a2b8; color: white; }
|
| 168 |
+
.btn-warning { background-color: #ffc107; color: black; }
|
| 169 |
+
.btn-danger { background-color: #dc3545; color: white; }
|
| 170 |
+
.btn-success { background-color: #28a745; color: white; }
|
| 171 |
+
.pagination {
|
| 172 |
+
display: flex;
|
| 173 |
+
justify-content: center;
|
| 174 |
+
align-items: center;
|
| 175 |
+
gap: 20px;
|
| 176 |
+
margin-top: 20px;
|
| 177 |
+
}
|
| 178 |
+
.page-info {
|
| 179 |
+
font-size: 0.9em;
|
| 180 |
+
color: #666;
|
| 181 |
+
}
|
| 182 |
+
</style>
|
| 183 |
+
|
| 184 |
+
<script>
|
| 185 |
+
function enableUser(token) {
|
| 186 |
+
if (confirm('Are you sure you want to enable this user?')) {
|
| 187 |
+
fetch(`/admin/users/${token}/enable`, {
|
| 188 |
+
method: 'POST',
|
| 189 |
+
headers: {
|
| 190 |
+
'Content-Type': 'application/json',
|
| 191 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 192 |
+
}
|
| 193 |
+
})
|
| 194 |
+
.then(response => response.json())
|
| 195 |
+
.then(data => {
|
| 196 |
+
if (data.success) {
|
| 197 |
+
location.reload();
|
| 198 |
+
} else {
|
| 199 |
+
alert('Error: ' + data.error);
|
| 200 |
+
}
|
| 201 |
+
});
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function disableUser(token) {
|
| 206 |
+
const reason = prompt('Reason for disabling user:');
|
| 207 |
+
if (reason) {
|
| 208 |
+
fetch(`/admin/users/${token}/disable`, {
|
| 209 |
+
method: 'POST',
|
| 210 |
+
headers: {
|
| 211 |
+
'Content-Type': 'application/json',
|
| 212 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 213 |
+
},
|
| 214 |
+
body: JSON.stringify({ reason: reason })
|
| 215 |
+
})
|
| 216 |
+
.then(response => response.json())
|
| 217 |
+
.then(data => {
|
| 218 |
+
if (data.success) {
|
| 219 |
+
location.reload();
|
| 220 |
+
} else {
|
| 221 |
+
alert('Error: ' + data.error);
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function deleteUser(token) {
|
| 228 |
+
if (confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
| 229 |
+
fetch(`/admin/users/${token}`, {
|
| 230 |
+
method: 'DELETE',
|
| 231 |
+
headers: {
|
| 232 |
+
'Content-Type': 'application/json',
|
| 233 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 234 |
+
}
|
| 235 |
+
})
|
| 236 |
+
.then(response => response.json())
|
| 237 |
+
.then(data => {
|
| 238 |
+
if (data.success) {
|
| 239 |
+
location.reload();
|
| 240 |
+
} else {
|
| 241 |
+
alert('Error: ' + data.error);
|
| 242 |
+
}
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
</script>
|
| 247 |
+
{% endblock %}
|
pages/admin/login.html
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Admin Login - {{ config.brand_name }}</title>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/nyancat.css') }}">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main>
|
| 11 |
+
<div class="login-container purr-card">
|
| 12 |
+
<h1>{{ config.brand_emoji }} {{ config.brand_name }} Admin Purr-nel</h1>
|
| 13 |
+
<p>πΎ Welcome back, hooman! Please enter your secret cat code to access the admin purr-nel.</p>
|
| 14 |
+
|
| 15 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 16 |
+
{% if messages %}
|
| 17 |
+
{% for category, message in messages %}
|
| 18 |
+
<div class="flash flash-{{ category }}">
|
| 19 |
+
{{ message }}
|
| 20 |
+
</div>
|
| 21 |
+
{% endfor %}
|
| 22 |
+
{% endif %}
|
| 23 |
+
{% endwith %}
|
| 24 |
+
|
| 25 |
+
<form method="POST" action="{{ url_for('admin.login') }}">
|
| 26 |
+
{{ csrf_token() }}
|
| 27 |
+
<div class="form-group">
|
| 28 |
+
<label for="admin_key">Admin Key:</label>
|
| 29 |
+
<input type="password" id="admin_key" name="admin_key" required>
|
| 30 |
+
</div>
|
| 31 |
+
<button type="submit">Login</button>
|
| 32 |
+
</form>
|
| 33 |
+
</div>
|
| 34 |
+
</main>
|
| 35 |
+
|
| 36 |
+
<style>
|
| 37 |
+
.login-container {
|
| 38 |
+
max-width: 400px;
|
| 39 |
+
margin: 50px auto;
|
| 40 |
+
padding: 20px;
|
| 41 |
+
border: 1px solid #ddd;
|
| 42 |
+
border-radius: 5px;
|
| 43 |
+
}
|
| 44 |
+
.form-group {
|
| 45 |
+
margin-bottom: 15px;
|
| 46 |
+
}
|
| 47 |
+
.form-group label {
|
| 48 |
+
display: block;
|
| 49 |
+
margin-bottom: 5px;
|
| 50 |
+
}
|
| 51 |
+
.form-group input {
|
| 52 |
+
width: 100%;
|
| 53 |
+
padding: 8px;
|
| 54 |
+
border: 1px solid #ddd;
|
| 55 |
+
border-radius: 3px;
|
| 56 |
+
}
|
| 57 |
+
.flash {
|
| 58 |
+
padding: 10px;
|
| 59 |
+
margin-bottom: 15px;
|
| 60 |
+
border-radius: 3px;
|
| 61 |
+
}
|
| 62 |
+
.flash-error {
|
| 63 |
+
background-color: #f8d7da;
|
| 64 |
+
color: #721c24;
|
| 65 |
+
border: 1px solid #f5c6cb;
|
| 66 |
+
}
|
| 67 |
+
.flash-success {
|
| 68 |
+
background-color: #d4edda;
|
| 69 |
+
color: #155724;
|
| 70 |
+
border: 1px solid #c3e6cb;
|
| 71 |
+
}
|
| 72 |
+
.flash-info {
|
| 73 |
+
background-color: #d1ecf1;
|
| 74 |
+
color: #0c5460;
|
| 75 |
+
border: 1px solid #bee5eb;
|
| 76 |
+
}
|
| 77 |
+
.flash-warning {
|
| 78 |
+
background-color: #fff3cd;
|
| 79 |
+
color: #856404;
|
| 80 |
+
border: 1px solid #ffeaa7;
|
| 81 |
+
}
|
| 82 |
+
</style>
|
| 83 |
+
</body>
|
| 84 |
+
</html>
|
pages/admin/model_families.html
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Model Families Management{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block head %}
|
| 6 |
+
<style>
|
| 7 |
+
.model-grid {
|
| 8 |
+
display: grid;
|
| 9 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 10 |
+
gap: 20px;
|
| 11 |
+
margin: 20px 0;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.provider-card {
|
| 15 |
+
background: white;
|
| 16 |
+
border-radius: 10px;
|
| 17 |
+
padding: 20px;
|
| 18 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 19 |
+
border-left: 4px solid var(--cat-primary);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.provider-header {
|
| 23 |
+
display: flex;
|
| 24 |
+
justify-content: space-between;
|
| 25 |
+
align-items: center;
|
| 26 |
+
margin-bottom: 15px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.provider-name {
|
| 30 |
+
font-size: 1.2em;
|
| 31 |
+
font-weight: bold;
|
| 32 |
+
color: var(--cat-primary);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.provider-stats {
|
| 36 |
+
font-size: 0.9em;
|
| 37 |
+
color: #666;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.model-list {
|
| 41 |
+
max-height: 300px;
|
| 42 |
+
overflow-y: auto;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.model-item {
|
| 46 |
+
display: flex;
|
| 47 |
+
justify-content: space-between;
|
| 48 |
+
align-items: center;
|
| 49 |
+
padding: 8px 0;
|
| 50 |
+
border-bottom: 1px solid #eee;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.model-info {
|
| 54 |
+
flex: 1;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.model-name {
|
| 58 |
+
font-weight: bold;
|
| 59 |
+
color: #333;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.model-description {
|
| 63 |
+
font-size: 0.8em;
|
| 64 |
+
color: #666;
|
| 65 |
+
margin-top: 2px;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.model-cost {
|
| 69 |
+
font-size: 0.8em;
|
| 70 |
+
color: #888;
|
| 71 |
+
margin-top: 2px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.model-toggle {
|
| 75 |
+
margin-left: 10px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.toggle-switch {
|
| 79 |
+
position: relative;
|
| 80 |
+
display: inline-block;
|
| 81 |
+
width: 40px;
|
| 82 |
+
height: 20px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.toggle-switch input {
|
| 86 |
+
opacity: 0;
|
| 87 |
+
width: 0;
|
| 88 |
+
height: 0;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.toggle-slider {
|
| 92 |
+
position: absolute;
|
| 93 |
+
cursor: pointer;
|
| 94 |
+
top: 0;
|
| 95 |
+
left: 0;
|
| 96 |
+
right: 0;
|
| 97 |
+
bottom: 0;
|
| 98 |
+
background-color: #ccc;
|
| 99 |
+
transition: .4s;
|
| 100 |
+
border-radius: 20px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.toggle-slider:before {
|
| 104 |
+
position: absolute;
|
| 105 |
+
content: "";
|
| 106 |
+
height: 16px;
|
| 107 |
+
width: 16px;
|
| 108 |
+
left: 2px;
|
| 109 |
+
bottom: 2px;
|
| 110 |
+
background-color: white;
|
| 111 |
+
transition: .4s;
|
| 112 |
+
border-radius: 50%;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
input:checked + .toggle-slider {
|
| 116 |
+
background-color: var(--cat-primary);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
input:checked + .toggle-slider:before {
|
| 120 |
+
transform: translateX(20px);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.stats-overview {
|
| 124 |
+
display: grid;
|
| 125 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 126 |
+
gap: 15px;
|
| 127 |
+
margin-bottom: 30px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.stat-card {
|
| 131 |
+
background: white;
|
| 132 |
+
padding: 15px;
|
| 133 |
+
border-radius: 8px;
|
| 134 |
+
text-align: center;
|
| 135 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.stat-value {
|
| 139 |
+
font-size: 1.8em;
|
| 140 |
+
font-weight: bold;
|
| 141 |
+
color: var(--cat-primary);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.stat-label {
|
| 145 |
+
font-size: 0.9em;
|
| 146 |
+
color: #666;
|
| 147 |
+
margin-top: 5px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.premium-badge {
|
| 151 |
+
background: linear-gradient(45deg, #ffd700, #ff8c00);
|
| 152 |
+
color: white;
|
| 153 |
+
font-size: 0.7em;
|
| 154 |
+
padding: 2px 6px;
|
| 155 |
+
border-radius: 10px;
|
| 156 |
+
margin-left: 5px;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.cat-personality {
|
| 160 |
+
font-size: 1.2em;
|
| 161 |
+
margin-right: 5px;
|
| 162 |
+
}
|
| 163 |
+
</style>
|
| 164 |
+
{% endblock %}
|
| 165 |
+
|
| 166 |
+
{% block content %}
|
| 167 |
+
<div class="container">
|
| 168 |
+
<h2>πΎ Model Families Management</h2>
|
| 169 |
+
<p>Manage which AI models are available for your cats to use!</p>
|
| 170 |
+
|
| 171 |
+
<!-- Stats Overview -->
|
| 172 |
+
<div class="stats-overview">
|
| 173 |
+
<div class="stat-card">
|
| 174 |
+
<div class="stat-value">{{ total_models }}</div>
|
| 175 |
+
<div class="stat-label">Total Models</div>
|
| 176 |
+
</div>
|
| 177 |
+
<div class="stat-card">
|
| 178 |
+
<div class="stat-value">{{ total_whitelisted }}</div>
|
| 179 |
+
<div class="stat-label">Whitelisted</div>
|
| 180 |
+
</div>
|
| 181 |
+
<div class="stat-card">
|
| 182 |
+
<div class="stat-value">${{ "%.2f"|format(cost_analysis.total_cost) }}</div>
|
| 183 |
+
<div class="stat-label">Total Cost</div>
|
| 184 |
+
</div>
|
| 185 |
+
<div class="stat-card">
|
| 186 |
+
<div class="stat-value">{{ providers|length }}</div>
|
| 187 |
+
<div class="stat-label">AI Providers</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<!-- Action Buttons -->
|
| 192 |
+
<div style="margin-bottom: 20px;">
|
| 193 |
+
<button onclick="enableAllModels()" class="btn btn-success">π± Enable All Models</button>
|
| 194 |
+
<button onclick="disableAllModels()" class="btn btn-warning">πΎ Disable All Models</button>
|
| 195 |
+
<a href="{{ url_for('model_families.usage_analytics') }}" class="btn btn-info">π Usage Analytics</a>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<!-- Provider Cards -->
|
| 199 |
+
<div class="model-grid">
|
| 200 |
+
{% for provider_key, provider_data in providers.items() %}
|
| 201 |
+
<div class="provider-card">
|
| 202 |
+
<div class="provider-header">
|
| 203 |
+
<div class="provider-name">{{ provider_data.name }}</div>
|
| 204 |
+
<div class="provider-stats">
|
| 205 |
+
{{ provider_data.whitelisted_count }}/{{ provider_data.total_count }} enabled
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div class="model-list">
|
| 210 |
+
{% for model in provider_data.all_models %}
|
| 211 |
+
<div class="model-item">
|
| 212 |
+
<div class="model-info">
|
| 213 |
+
<div class="model-name">
|
| 214 |
+
<span class="cat-personality">{{ model.cat_emoji }}</span>
|
| 215 |
+
{{ model.display_name }}
|
| 216 |
+
{% if model.is_premium %}
|
| 217 |
+
<span class="premium-badge">PREMIUM</span>
|
| 218 |
+
{% endif %}
|
| 219 |
+
</div>
|
| 220 |
+
<div class="model-description">{{ model.description }}</div>
|
| 221 |
+
<div class="model-cost">
|
| 222 |
+
Input: ${{ "%.3f"|format(model.input_cost) }}/1K |
|
| 223 |
+
Output: ${{ "%.3f"|format(model.output_cost) }}/1K |
|
| 224 |
+
Context: {{ "{:,}".format(model.context_length) }}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
<div class="model-toggle">
|
| 228 |
+
<label class="toggle-switch">
|
| 229 |
+
<input type="checkbox"
|
| 230 |
+
{% if model.is_whitelisted %}checked{% endif %}
|
| 231 |
+
onchange="toggleModel('{{ provider_key }}', '{{ model.id }}', this)"
|
| 232 |
+
data-model-id="{{ model.id }}"
|
| 233 |
+
data-provider="{{ provider_key }}">
|
| 234 |
+
<span class="toggle-slider"></span>
|
| 235 |
+
</label>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
{% endfor %}
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
<div style="margin-top: 15px;">
|
| 242 |
+
<button onclick="toggleAllProviderModels('{{ provider_key }}', true)"
|
| 243 |
+
class="btn btn-sm btn-success">Enable All</button>
|
| 244 |
+
<button onclick="toggleAllProviderModels('{{ provider_key }}', false)"
|
| 245 |
+
class="btn btn-sm btn-secondary">Disable All</button>
|
| 246 |
+
<a href="{{ url_for('model_families.provider_details', provider_name=provider_key) }}"
|
| 247 |
+
class="btn btn-sm btn-info">Details</a>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
{% endfor %}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
<script>
|
| 255 |
+
function toggleModel(provider, modelId, checkbox) {
|
| 256 |
+
fetch(`/admin/model-families/api/model/${modelId}/toggle`, {
|
| 257 |
+
method: 'POST',
|
| 258 |
+
headers: {
|
| 259 |
+
'Content-Type': 'application/json',
|
| 260 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 261 |
+
}
|
| 262 |
+
})
|
| 263 |
+
.then(response => response.json())
|
| 264 |
+
.then(data => {
|
| 265 |
+
if (data.success) {
|
| 266 |
+
showNotification(data.message, 'success');
|
| 267 |
+
updateProviderStats(provider);
|
| 268 |
+
} else {
|
| 269 |
+
showNotification(data.error || 'Failed to update model', 'error');
|
| 270 |
+
checkbox.checked = !checkbox.checked; // Revert checkbox
|
| 271 |
+
}
|
| 272 |
+
})
|
| 273 |
+
.catch(error => {
|
| 274 |
+
console.error('Error:', error);
|
| 275 |
+
showNotification('Network error occurred', 'error');
|
| 276 |
+
checkbox.checked = !checkbox.checked; // Revert checkbox
|
| 277 |
+
});
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function toggleAllProviderModels(provider, enable) {
|
| 281 |
+
const checkboxes = document.querySelectorAll(`input[data-provider="${provider}"]`);
|
| 282 |
+
const modelIds = [];
|
| 283 |
+
|
| 284 |
+
checkboxes.forEach(checkbox => {
|
| 285 |
+
if (enable !== checkbox.checked) {
|
| 286 |
+
checkbox.checked = enable;
|
| 287 |
+
modelIds.push(checkbox.getAttribute('data-model-id'));
|
| 288 |
+
}
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
if (modelIds.length === 0) {
|
| 292 |
+
showNotification('No changes needed', 'info');
|
| 293 |
+
return;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// Get all models for this provider
|
| 297 |
+
const allModels = Array.from(checkboxes).map(cb => cb.getAttribute('data-model-id'));
|
| 298 |
+
const enabledModels = enable ? allModels : allModels.filter(id => !modelIds.includes(id));
|
| 299 |
+
|
| 300 |
+
updateProviderWhitelist(provider, enabledModels);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function enableAllModels() {
|
| 304 |
+
const allCheckboxes = document.querySelectorAll('input[data-model-id]');
|
| 305 |
+
allCheckboxes.forEach(checkbox => {
|
| 306 |
+
if (!checkbox.checked) {
|
| 307 |
+
checkbox.checked = true;
|
| 308 |
+
}
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
// Update all providers
|
| 312 |
+
{% for provider_key in providers.keys() %}
|
| 313 |
+
const {{ provider_key }}Models = Array.from(document.querySelectorAll(`input[data-provider="{{ provider_key }}"]`))
|
| 314 |
+
.map(cb => cb.getAttribute('data-model-id'));
|
| 315 |
+
updateProviderWhitelist('{{ provider_key }}', {{ provider_key }}Models);
|
| 316 |
+
{% endfor %}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function disableAllModels() {
|
| 320 |
+
if (!confirm('Are you sure you want to disable ALL models? This will prevent any API calls from working!')) {
|
| 321 |
+
return;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
const allCheckboxes = document.querySelectorAll('input[data-model-id]');
|
| 325 |
+
allCheckboxes.forEach(checkbox => {
|
| 326 |
+
checkbox.checked = false;
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
// Update all providers
|
| 330 |
+
{% for provider_key in providers.keys() %}
|
| 331 |
+
updateProviderWhitelist('{{ provider_key }}', []);
|
| 332 |
+
{% endfor %}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function updateProviderWhitelist(provider, modelIds) {
|
| 336 |
+
fetch('/admin/model-families/api/whitelist', {
|
| 337 |
+
method: 'POST',
|
| 338 |
+
headers: {
|
| 339 |
+
'Content-Type': 'application/json',
|
| 340 |
+
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
| 341 |
+
},
|
| 342 |
+
body: JSON.stringify({
|
| 343 |
+
provider: provider,
|
| 344 |
+
models: modelIds
|
| 345 |
+
})
|
| 346 |
+
})
|
| 347 |
+
.then(response => response.json())
|
| 348 |
+
.then(data => {
|
| 349 |
+
if (data.success) {
|
| 350 |
+
showNotification(data.message, 'success');
|
| 351 |
+
updateProviderStats(provider);
|
| 352 |
+
} else {
|
| 353 |
+
showNotification(data.error || 'Failed to update whitelist', 'error');
|
| 354 |
+
location.reload(); // Reload to reset checkboxes
|
| 355 |
+
}
|
| 356 |
+
})
|
| 357 |
+
.catch(error => {
|
| 358 |
+
console.error('Error:', error);
|
| 359 |
+
showNotification('Network error occurred', 'error');
|
| 360 |
+
location.reload(); // Reload to reset checkboxes
|
| 361 |
+
});
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
function updateProviderStats(provider) {
|
| 365 |
+
const enabledCount = document.querySelectorAll(`input[data-provider="${provider}"]:checked`).length;
|
| 366 |
+
const totalCount = document.querySelectorAll(`input[data-provider="${provider}"]`).length;
|
| 367 |
+
|
| 368 |
+
const statsElement = document.querySelector(`.provider-card:has(input[data-provider="${provider}"]) .provider-stats`);
|
| 369 |
+
if (statsElement) {
|
| 370 |
+
statsElement.textContent = `${enabledCount}/${totalCount} enabled`;
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function showNotification(message, type) {
|
| 375 |
+
const notification = document.createElement('div');
|
| 376 |
+
notification.className = `flash flash-${type}`;
|
| 377 |
+
notification.textContent = message;
|
| 378 |
+
notification.style.position = 'fixed';
|
| 379 |
+
notification.style.top = '20px';
|
| 380 |
+
notification.style.right = '20px';
|
| 381 |
+
notification.style.zIndex = '9999';
|
| 382 |
+
notification.style.padding = '10px 20px';
|
| 383 |
+
notification.style.borderRadius = '5px';
|
| 384 |
+
notification.style.color = 'white';
|
| 385 |
+
notification.style.fontSize = '14px';
|
| 386 |
+
|
| 387 |
+
if (type === 'success') {
|
| 388 |
+
notification.style.backgroundColor = '#28a745';
|
| 389 |
+
} else if (type === 'error') {
|
| 390 |
+
notification.style.backgroundColor = '#dc3545';
|
| 391 |
+
} else if (type === 'info') {
|
| 392 |
+
notification.style.backgroundColor = '#17a2b8';
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
document.body.appendChild(notification);
|
| 396 |
+
|
| 397 |
+
setTimeout(() => {
|
| 398 |
+
notification.remove();
|
| 399 |
+
}, 3000);
|
| 400 |
+
}
|
| 401 |
+
</script>
|
| 402 |
+
{% endblock %}
|
pages/admin/stats.html
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Statistics{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="stats-page">
|
| 7 |
+
<h1>System Statistics</h1>
|
| 8 |
+
|
| 9 |
+
<div class="stats-overview">
|
| 10 |
+
<div class="stat-card">
|
| 11 |
+
<h3>Total Users</h3>
|
| 12 |
+
<p class="stat-number">{{ stats.total_users }}</p>
|
| 13 |
+
</div>
|
| 14 |
+
<div class="stat-card">
|
| 15 |
+
<h3>Active Users</h3>
|
| 16 |
+
<p class="stat-number">{{ stats.active_users }}</p>
|
| 17 |
+
</div>
|
| 18 |
+
<div class="stat-card">
|
| 19 |
+
<h3>Total Requests</h3>
|
| 20 |
+
<p class="stat-number">{{ stats.usage_stats.total_requests or 0 }}</p>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="stat-card">
|
| 23 |
+
<h3>Total Tokens</h3>
|
| 24 |
+
<p class="stat-number">{{ stats.usage_stats.total_tokens or 0 }}</p>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="charts-section">
|
| 29 |
+
<h2>Usage Analytics</h2>
|
| 30 |
+
<p><em>Advanced analytics and charts coming soon...</em></p>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<style>
|
| 35 |
+
.stats-overview {
|
| 36 |
+
display: grid;
|
| 37 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 38 |
+
gap: 20px;
|
| 39 |
+
margin-bottom: 40px;
|
| 40 |
+
}
|
| 41 |
+
.stat-card {
|
| 42 |
+
background: #f8f9fa;
|
| 43 |
+
padding: 20px;
|
| 44 |
+
border-radius: 5px;
|
| 45 |
+
text-align: center;
|
| 46 |
+
border: 1px solid #dee2e6;
|
| 47 |
+
}
|
| 48 |
+
.stat-number {
|
| 49 |
+
font-size: 2em;
|
| 50 |
+
font-weight: bold;
|
| 51 |
+
color: #007bff;
|
| 52 |
+
margin: 10px 0;
|
| 53 |
+
}
|
| 54 |
+
.charts-section {
|
| 55 |
+
background: #f8f9fa;
|
| 56 |
+
padding: 20px;
|
| 57 |
+
border-radius: 5px;
|
| 58 |
+
}
|
| 59 |
+
</style>
|
| 60 |
+
{% endblock %}
|
pages/admin/view_user.html
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "admin/base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}View User{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
{% set user = user_data.user %}
|
| 7 |
+
<div class="view-user">
|
| 8 |
+
<div class="user-header">
|
| 9 |
+
<h1>User Details</h1>
|
| 10 |
+
<div class="user-actions">
|
| 11 |
+
<a href="{{ url_for('admin.edit_user', token=user.token) }}" class="btn btn-primary">Edit User</a>
|
| 12 |
+
{% if user.disabled_at %}
|
| 13 |
+
<button class="btn btn-success" onclick="enableUser('{{ user.token }}')">Enable User</button>
|
| 14 |
+
{% else %}
|
| 15 |
+
<button class="btn btn-warning" onclick="disableUser('{{ user.token }}')">Disable User</button>
|
| 16 |
+
{% endif %}
|
| 17 |
+
<button class="btn btn-info" onclick="rotateUserToken('{{ user.token }}')">Rotate Token</button>
|
| 18 |
+
<button class="btn btn-danger" onclick="deleteUser('{{ user.token }}')">Delete User</button>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="user-info">
|
| 23 |
+
<div class="info-section">
|
| 24 |
+
<h2>Basic Information</h2>
|
| 25 |
+
<table class="info-table">
|
| 26 |
+
<tr>
|
| 27 |
+
<td><strong>Token:</strong></td>
|
| 28 |
+
<td>
|
| 29 |
+
<code>{{ user.token }}</code>
|
| 30 |
+
<button class="btn btn-sm btn-copy" onclick="copyToClipboard('{{ user.token }}')">Copy</button>
|
| 31 |
+
</td>
|
| 32 |
+
</tr>
|
| 33 |
+
<tr>
|
| 34 |
+
<td><strong>Nickname:</strong></td>
|
| 35 |
+
<td>{{ user.nickname or 'N/A' }}</td>
|
| 36 |
+
</tr>
|
| 37 |
+
<tr>
|
| 38 |
+
<td><strong>Type:</strong></td>
|
| 39 |
+
<td><span class="badge badge-{{ user.type }}">{{ user.type }}</span></td>
|
| 40 |
+
</tr>
|
| 41 |
+
<tr>
|
| 42 |
+
<td><strong>Created:</strong></td>
|
| 43 |
+
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
| 44 |
+
</tr>
|
| 45 |
+
<tr>
|
| 46 |
+
<td><strong>Last Used:</strong></td>
|
| 47 |
+
<td>{{ user.last_used.strftime('%Y-%m-%d %H:%M:%S') if user.last_used else 'Never' }}</td>
|
| 48 |
+
</tr>
|
| 49 |
+
<tr>
|
| 50 |
+
<td><strong>Status:</strong></td>
|
| 51 |
+
<td>
|
| 52 |
+
{% if user.disabled_at %}
|
| 53 |
+
<span class="status-disabled">Disabled</span>
|
| 54 |
+
<small>({{ user.disabled_reason or 'No reason provided' }})</small>
|
| 55 |
+
{% else %}
|
| 56 |
+
<span class="status-active">Active</span>
|
| 57 |
+
{% endif %}
|
| 58 |
+
</td>
|
| 59 |
+
</tr>
|
| 60 |
+
</table>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<div class="info-section">
|
| 64 |
+
<h2>IP Addresses</h2>
|
| 65 |
+
{% if user.ip %}
|
| 66 |
+
<ul class="ip-list">
|
| 67 |
+
{% for ip in user.ip %}
|
| 68 |
+
<li>{{ ip[:16] }}... (hashed)</li>
|
| 69 |
+
{% endfor %}
|
| 70 |
+
</ul>
|
| 71 |
+
<p><small>{{ user.ip|length }} IP address(es) registered</small></p>
|
| 72 |
+
{% else %}
|
| 73 |
+
<p><em>No IP addresses registered</em></p>
|
| 74 |
+
{% endif %}
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="info-section">
|
| 78 |
+
<h2>Token Usage</h2>
|
| 79 |
+
<table class="usage-table">
|
| 80 |
+
<thead>
|
| 81 |
+
<tr>
|
| 82 |
+
<th>Model Family</th>
|
| 83 |
+
<th>Input Tokens</th>
|
| 84 |
+
<th>Output Tokens</th>
|
| 85 |
+
<th>Total Tokens</th>
|
| 86 |
+
<th>Limit</th>
|
| 87 |
+
</tr>
|
| 88 |
+
</thead>
|
| 89 |
+
<tbody>
|
| 90 |
+
{% for family, count in user.token_counts.items() %}
|
| 91 |
+
<tr>
|
| 92 |
+
<td>{{ family }}</td>
|
| 93 |
+
<td>{{ count.input }}</td>
|
| 94 |
+
<td>{{ count.output }}</td>
|
| 95 |
+
<td>{{ count.total }}</td>
|
| 96 |
+
<td>{{ user.token_limits.get(family, 'Unlimited') }}</td>
|
| 97 |
+
</tr>
|
| 98 |
+
{% endfor %}
|
| 99 |
+
</tbody>
|
| 100 |
+
</table>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div class="info-section">
|
| 104 |
+
<h2>Usage Statistics</h2>
|
| 105 |
+
{% if user_data.usage_stats %}
|
| 106 |
+
<div class="usage-stats">
|
| 107 |
+
<div class="stat-item">
|
| 108 |
+
<strong>Total Requests:</strong> {{ user_data.usage_stats.total_requests }}
|
| 109 |
+
</div>
|
| 110 |
+
<div class="stat-item">
|
| 111 |
+
<strong>Total Tokens:</strong> {{ user_data.usage_stats.total_tokens }}
|
| 112 |
+
</div>
|
| 113 |
+
<div class="stat-item">
|
| 114 |
+
<strong>Total Cost:</strong> ${{ "%.4f"|format(user_data.usage_stats.total_cost) }}
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
{% else %}
|
| 118 |
+
<p><em>No usage statistics available</em></p>
|
| 119 |
+
{% endif %}
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div class="info-section">
|
| 123 |
+
<h2>Recent Activity</h2>
|
| 124 |
+
{% if user_data.recent_events %}
|
| 125 |
+
<table class="events-table">
|
| 126 |
+
<thead>
|
| 127 |
+
<tr>
|
| 128 |
+
<th>Time</th>
|
| 129 |
+
<th>Event</th>
|
| 130 |
+
<th>Details</th>
|
| 131 |
+
</tr>
|
| 132 |
+
</thead>
|
| 133 |
+
<tbody>
|
| 134 |
+
{% for event in user_data.recent_events %}
|
| 135 |
+
<tr>
|
| 136 |
+
<td>{{ event.timestamp or 'N/A' }}</td>
|
| 137 |
+
<td>{{ event.event_type }}</td>
|
| 138 |
+
<td>
|
| 139 |
+
{% if event.payload.action %}
|
| 140 |
+
{{ event.payload.action }}
|
| 141 |
+
{% elif event.payload.model_family %}
|
| 142 |
+
{{ event.payload.model_family }} - {{ event.payload.total_tokens }} tokens
|
| 143 |
+
{% else %}
|
| 144 |
+
{{ event.payload | truncate(50) }}
|
| 145 |
+
{% endif %}
|
| 146 |
+
</td>
|
| 147 |
+
</tr>
|
| 148 |
+
{% endfor %}
|
| 149 |
+
</tbody>
|
| 150 |
+
</table>
|
| 151 |
+
{% else %}
|
| 152 |
+
<p><em>No recent activity</em></p>
|
| 153 |
+
{% endif %}
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<style>
|
| 159 |
+
.user-header {
|
| 160 |
+
display: flex;
|
| 161 |
+
justify-content: space-between;
|
| 162 |
+
align-items: center;
|
| 163 |
+
margin-bottom: 30px;
|
| 164 |
+
padding-bottom: 15px;
|
| 165 |
+
border-bottom: 1px solid #ddd;
|
| 166 |
+
}
|
| 167 |
+
.user-actions {
|
| 168 |
+
display: flex;
|
| 169 |
+
gap: 10px;
|
| 170 |
+
}
|
| 171 |
+
.info-section {
|
| 172 |
+
margin-bottom: 30px;
|
| 173 |
+
padding: 20px;
|
| 174 |
+
background: #f8f9fa;
|
| 175 |
+
border-radius: 5px;
|
| 176 |
+
}
|
| 177 |
+
.info-section h2 {
|
| 178 |
+
margin-top: 0;
|
| 179 |
+
margin-bottom: 15px;
|
| 180 |
+
color: #495057;
|
| 181 |
+
}
|
| 182 |
+
.info-table {
|
| 183 |
+
width: 100%;
|
| 184 |
+
border-collapse: collapse;
|
| 185 |
+
}
|
| 186 |
+
.info-table td {
|
| 187 |
+
padding: 8px;
|
| 188 |
+
border-bottom: 1px solid #dee2e6;
|
| 189 |
+
}
|
| 190 |
+
.info-table td:first-child {
|
| 191 |
+
width: 150px;
|
| 192 |
+
vertical-align: top;
|
| 193 |
+
}
|
| 194 |
+
.badge {
|
| 195 |
+
padding: 3px 8px;
|
| 196 |
+
border-radius: 3px;
|
| 197 |
+
font-size: 0.8em;
|
| 198 |
+
}
|
| 199 |
+
.badge-normal { background-color: #007bff; color: white; }
|
| 200 |
+
.badge-special { background-color: #28a745; color: white; }
|
| 201 |
+
.badge-temporary { background-color: #ffc107; color: black; }
|
| 202 |
+
.status-active { color: #28a745; }
|
| 203 |
+
.status-disabled { color: #dc3545; }
|
| 204 |
+
.ip-list {
|
| 205 |
+
list-style: none;
|
| 206 |
+
padding: 0;
|
| 207 |
+
}
|
| 208 |
+
.ip-list li {
|
| 209 |
+
padding: 5px 0;
|
| 210 |
+
border-bottom: 1px solid #eee;
|
| 211 |
+
}
|
| 212 |
+
.usage-table,
|
| 213 |
+
.events-table {
|
| 214 |
+
width: 100%;
|
| 215 |
+
border-collapse: collapse;
|
| 216 |
+
margin-top: 10px;
|
| 217 |
+
}
|
| 218 |
+
.usage-table th,
|
| 219 |
+
.usage-table td,
|
| 220 |
+
.events-table th,
|
| 221 |
+
.events-table td {
|
| 222 |
+
padding: 8px;
|
| 223 |
+
border: 1px solid #dee2e6;
|
| 224 |
+
text-align: left;
|
| 225 |
+
}
|
| 226 |
+
.usage-table th,
|
| 227 |
+
.events-table th {
|
| 228 |
+
background-color: #f8f9fa;
|
| 229 |
+
}
|
| 230 |
+
.usage-stats {
|
| 231 |
+
display: grid;
|
| 232 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 233 |
+
gap: 15px;
|
| 234 |
+
}
|
| 235 |
+
.stat-item {
|
| 236 |
+
padding: 10px;
|
| 237 |
+
background: white;
|
| 238 |
+
border-radius: 3px;
|
| 239 |
+
border: 1px solid #dee2e6;
|
| 240 |
+
}
|
| 241 |
+
.btn-copy {
|
| 242 |
+
margin-left: 10px;
|
| 243 |
+
padding: 2px 8px;
|
| 244 |
+
font-size: 0.8em;
|
| 245 |
+
background-color: #6c757d;
|
| 246 |
+
color: white;
|
| 247 |
+
border: none;
|
| 248 |
+
border-radius: 2px;
|
| 249 |
+
cursor: pointer;
|
| 250 |
+
}
|
| 251 |
+
.btn-copy:hover {
|
| 252 |
+
background-color: #5a6268;
|
| 253 |
+
}
|
| 254 |
+
.copy-success {
|
| 255 |
+
position: fixed;
|
| 256 |
+
top: 20px;
|
| 257 |
+
right: 20px;
|
| 258 |
+
padding: 10px 20px;
|
| 259 |
+
background-color: #28a745;
|
| 260 |
+
color: white;
|
| 261 |
+
border-radius: 5px;
|
| 262 |
+
z-index: 1000;
|
| 263 |
+
}
|
| 264 |
+
</style>
|
| 265 |
+
{% endblock %}
|
pages/admin_dashboard.html
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Admin Dashboard - {{ config.brand_name }}</title>
|
| 7 |
+
<style>
|
| 8 |
+
body {
|
| 9 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 10 |
+
margin: 0;
|
| 11 |
+
padding: 20px;
|
| 12 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 13 |
+
min-height: 100vh;
|
| 14 |
+
color: #333;
|
| 15 |
+
max-width: none !important;
|
| 16 |
+
width: 100% !important;
|
| 17 |
+
}
|
| 18 |
+
.container {
|
| 19 |
+
max-width: 1200px;
|
| 20 |
+
margin: 0 auto;
|
| 21 |
+
background: white;
|
| 22 |
+
border-radius: 15px;
|
| 23 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
| 24 |
+
padding: 40px;
|
| 25 |
+
}
|
| 26 |
+
h1 {
|
| 27 |
+
text-align: center;
|
| 28 |
+
color: #4a5568;
|
| 29 |
+
margin-bottom: 30px;
|
| 30 |
+
font-size: 2.5em;
|
| 31 |
+
}
|
| 32 |
+
.stats-grid {
|
| 33 |
+
display: grid;
|
| 34 |
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
| 35 |
+
gap: 20px;
|
| 36 |
+
margin-bottom: 30px;
|
| 37 |
+
}
|
| 38 |
+
.stat-card {
|
| 39 |
+
background: #f8f9fa;
|
| 40 |
+
padding: 20px;
|
| 41 |
+
border-radius: 10px;
|
| 42 |
+
border-left: 4px solid #667eea;
|
| 43 |
+
text-align: center;
|
| 44 |
+
}
|
| 45 |
+
.stat-card h3 {
|
| 46 |
+
margin: 0 0 10px 0;
|
| 47 |
+
color: #4a5568;
|
| 48 |
+
font-size: 1.2em;
|
| 49 |
+
}
|
| 50 |
+
.stat-card .value {
|
| 51 |
+
font-size: 2em;
|
| 52 |
+
font-weight: bold;
|
| 53 |
+
color: #667eea;
|
| 54 |
+
}
|
| 55 |
+
.actions {
|
| 56 |
+
display: flex;
|
| 57 |
+
gap: 15px;
|
| 58 |
+
margin-bottom: 30px;
|
| 59 |
+
flex-wrap: wrap;
|
| 60 |
+
}
|
| 61 |
+
.btn {
|
| 62 |
+
padding: 12px 24px;
|
| 63 |
+
border: none;
|
| 64 |
+
border-radius: 6px;
|
| 65 |
+
cursor: pointer;
|
| 66 |
+
font-size: 14px;
|
| 67 |
+
font-weight: 500;
|
| 68 |
+
text-decoration: none;
|
| 69 |
+
display: inline-block;
|
| 70 |
+
transition: all 0.3s ease;
|
| 71 |
+
}
|
| 72 |
+
.btn-primary {
|
| 73 |
+
background: #667eea;
|
| 74 |
+
color: white;
|
| 75 |
+
}
|
| 76 |
+
.btn-primary:hover {
|
| 77 |
+
background: #5a6fd8;
|
| 78 |
+
}
|
| 79 |
+
.btn-success {
|
| 80 |
+
background: #28a745;
|
| 81 |
+
color: white;
|
| 82 |
+
}
|
| 83 |
+
.btn-success:hover {
|
| 84 |
+
background: #218838;
|
| 85 |
+
}
|
| 86 |
+
.btn-danger {
|
| 87 |
+
background: #dc3545;
|
| 88 |
+
color: white;
|
| 89 |
+
}
|
| 90 |
+
.btn-danger:hover {
|
| 91 |
+
background: #c82333;
|
| 92 |
+
}
|
| 93 |
+
.user-table {
|
| 94 |
+
width: 100%;
|
| 95 |
+
border-collapse: collapse;
|
| 96 |
+
margin-top: 20px;
|
| 97 |
+
}
|
| 98 |
+
.user-table th,
|
| 99 |
+
.user-table td {
|
| 100 |
+
padding: 12px;
|
| 101 |
+
text-align: left;
|
| 102 |
+
border-bottom: 1px solid #ddd;
|
| 103 |
+
}
|
| 104 |
+
.user-table th {
|
| 105 |
+
background: #f8f9fa;
|
| 106 |
+
font-weight: 600;
|
| 107 |
+
color: #4a5568;
|
| 108 |
+
}
|
| 109 |
+
.user-table tr:hover {
|
| 110 |
+
background: #f8f9fa;
|
| 111 |
+
}
|
| 112 |
+
.status {
|
| 113 |
+
padding: 4px 8px;
|
| 114 |
+
border-radius: 4px;
|
| 115 |
+
font-size: 12px;
|
| 116 |
+
font-weight: bold;
|
| 117 |
+
}
|
| 118 |
+
.status.active {
|
| 119 |
+
background: #d4edda;
|
| 120 |
+
color: #155724;
|
| 121 |
+
}
|
| 122 |
+
.status.disabled {
|
| 123 |
+
background: #f8d7da;
|
| 124 |
+
color: #721c24;
|
| 125 |
+
}
|
| 126 |
+
.token-display {
|
| 127 |
+
font-family: monospace;
|
| 128 |
+
background: #f8f9fa;
|
| 129 |
+
padding: 2px 6px;
|
| 130 |
+
border-radius: 3px;
|
| 131 |
+
font-size: 12px;
|
| 132 |
+
}
|
| 133 |
+
.modal {
|
| 134 |
+
display: none;
|
| 135 |
+
position: fixed;
|
| 136 |
+
z-index: 1000;
|
| 137 |
+
left: 0;
|
| 138 |
+
top: 0;
|
| 139 |
+
width: 100%;
|
| 140 |
+
height: 100%;
|
| 141 |
+
background: rgba(0,0,0,0.5);
|
| 142 |
+
}
|
| 143 |
+
.modal-content {
|
| 144 |
+
background: white;
|
| 145 |
+
margin: 15% auto;
|
| 146 |
+
padding: 20px;
|
| 147 |
+
border-radius: 10px;
|
| 148 |
+
width: 80%;
|
| 149 |
+
max-width: 500px;
|
| 150 |
+
}
|
| 151 |
+
.close {
|
| 152 |
+
color: #aaa;
|
| 153 |
+
float: right;
|
| 154 |
+
font-size: 28px;
|
| 155 |
+
font-weight: bold;
|
| 156 |
+
cursor: pointer;
|
| 157 |
+
}
|
| 158 |
+
.close:hover {
|
| 159 |
+
color: black;
|
| 160 |
+
}
|
| 161 |
+
.form-group {
|
| 162 |
+
margin-bottom: 15px;
|
| 163 |
+
}
|
| 164 |
+
.form-group label {
|
| 165 |
+
display: block;
|
| 166 |
+
margin-bottom: 5px;
|
| 167 |
+
font-weight: 500;
|
| 168 |
+
}
|
| 169 |
+
.form-group input,
|
| 170 |
+
.form-group select,
|
| 171 |
+
.form-group textarea {
|
| 172 |
+
width: 100%;
|
| 173 |
+
padding: 8px;
|
| 174 |
+
border: 1px solid #ddd;
|
| 175 |
+
border-radius: 4px;
|
| 176 |
+
font-size: 14px;
|
| 177 |
+
}
|
| 178 |
+
.auth-config {
|
| 179 |
+
background: #e3f2fd;
|
| 180 |
+
padding: 15px;
|
| 181 |
+
border-radius: 8px;
|
| 182 |
+
margin-bottom: 20px;
|
| 183 |
+
border-left: 4px solid #2196f3;
|
| 184 |
+
}
|
| 185 |
+
.auth-config h3 {
|
| 186 |
+
margin-top: 0;
|
| 187 |
+
color: #1976d2;
|
| 188 |
+
}
|
| 189 |
+
.loading {
|
| 190 |
+
text-align: center;
|
| 191 |
+
padding: 20px;
|
| 192 |
+
color: #666;
|
| 193 |
+
}
|
| 194 |
+
.error {
|
| 195 |
+
background: #f8d7da;
|
| 196 |
+
color: #721c24;
|
| 197 |
+
padding: 10px;
|
| 198 |
+
border-radius: 4px;
|
| 199 |
+
margin-bottom: 15px;
|
| 200 |
+
}
|
| 201 |
+
.success {
|
| 202 |
+
background: #d4edda;
|
| 203 |
+
color: #155724;
|
| 204 |
+
padding: 10px;
|
| 205 |
+
border-radius: 4px;
|
| 206 |
+
margin-bottom: 15px;
|
| 207 |
+
}
|
| 208 |
+
</style>
|
| 209 |
+
</head>
|
| 210 |
+
<body>
|
| 211 |
+
<div class="container">
|
| 212 |
+
<h1>{{ config.brand_emoji }} Admin Dashboard</h1>
|
| 213 |
+
|
| 214 |
+
<div class="auth-config">
|
| 215 |
+
<h3>Authentication Configuration</h3>
|
| 216 |
+
<p><strong>Mode:</strong> {{ auth_mode }}</p>
|
| 217 |
+
<p><strong>User Token Mode:</strong> {{ 'Enabled' if auth_config.is_user_token_mode() else 'Disabled' }}</p>
|
| 218 |
+
<p><strong>Proxy Key Mode:</strong> {{ 'Enabled' if auth_config.is_proxy_key_mode() else 'Disabled' }}</p>
|
| 219 |
+
<p><strong>Rate Limiting:</strong> {{ 'Enabled' if auth_config.rate_limit_enabled else 'Disabled' }}</p>
|
| 220 |
+
<p><strong>Firebase Connected:</strong> {{ 'Yes' if firebase_connected else 'No' }}</p>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<div class="stats-grid">
|
| 224 |
+
<div class="stat-card">
|
| 225 |
+
<h3>Total Users</h3>
|
| 226 |
+
<div class="value" id="total-users">Loading...</div>
|
| 227 |
+
</div>
|
| 228 |
+
<div class="stat-card">
|
| 229 |
+
<h3>Active Users</h3>
|
| 230 |
+
<div class="value" id="active-users">Loading...</div>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="stat-card">
|
| 233 |
+
<h3>Disabled Users</h3>
|
| 234 |
+
<div class="value" id="disabled-users">Loading...</div>
|
| 235 |
+
</div>
|
| 236 |
+
<div class="stat-card">
|
| 237 |
+
<h3>Total Requests</h3>
|
| 238 |
+
<div class="value" id="total-requests">Loading...</div>
|
| 239 |
+
</div>
|
| 240 |
+
<div class="stat-card">
|
| 241 |
+
<h3>Total Tokens</h3>
|
| 242 |
+
<div class="value" id="total-tokens">Loading...</div>
|
| 243 |
+
</div>
|
| 244 |
+
<div class="stat-card">
|
| 245 |
+
<h3>Total Cost</h3>
|
| 246 |
+
<div class="value" id="total-cost">Loading...</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<div class="actions">
|
| 251 |
+
<button class="btn btn-primary" onclick="showCreateUserModal()">Create User</button>
|
| 252 |
+
<button class="btn btn-success" onclick="refreshQuotas()">Refresh All Quotas</button>
|
| 253 |
+
<button class="btn btn-primary" onclick="loadUsers()">Refresh Users</button>
|
| 254 |
+
<button class="btn btn-danger" onclick="exportUsers()">Export Users</button>
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<div id="user-list">
|
| 258 |
+
<h3>Users</h3>
|
| 259 |
+
<div class="loading">Loading users...</div>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<!-- Create User Modal -->
|
| 264 |
+
<div id="createUserModal" class="modal">
|
| 265 |
+
<div class="modal-content">
|
| 266 |
+
<span class="close" onclick="closeModal('createUserModal')">×</span>
|
| 267 |
+
<h2>Create New User</h2>
|
| 268 |
+
<form id="createUserForm">
|
| 269 |
+
<div class="form-group">
|
| 270 |
+
<label for="userType">User Type:</label>
|
| 271 |
+
<select id="userType" name="type">
|
| 272 |
+
<option value="normal">Normal</option>
|
| 273 |
+
<option value="special">Special</option>
|
| 274 |
+
<option value="temporary">Temporary</option>
|
| 275 |
+
</select>
|
| 276 |
+
</div>
|
| 277 |
+
<div class="form-group">
|
| 278 |
+
<label for="nickname">Nickname (Optional):</label>
|
| 279 |
+
<input type="text" id="nickname" name="nickname" placeholder="Enter nickname">
|
| 280 |
+
</div>
|
| 281 |
+
<div class="form-group">
|
| 282 |
+
<label for="openaiLimit">OpenAI Token Limit:</label>
|
| 283 |
+
<input type="number" id="openaiLimit" name="openai_limit" value="100000">
|
| 284 |
+
</div>
|
| 285 |
+
<div class="form-group">
|
| 286 |
+
<label for="anthropicLimit">Anthropic Token Limit:</label>
|
| 287 |
+
<input type="number" id="anthropicLimit" name="anthropic_limit" value="100000">
|
| 288 |
+
</div>
|
| 289 |
+
<button type="submit" class="btn btn-primary">Create User</button>
|
| 290 |
+
</form>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
|
| 294 |
+
<!-- Action Modal -->
|
| 295 |
+
<div id="actionModal" class="modal">
|
| 296 |
+
<div class="modal-content">
|
| 297 |
+
<span class="close" onclick="closeModal('actionModal')">×</span>
|
| 298 |
+
<h2 id="actionModalTitle">Action</h2>
|
| 299 |
+
<div id="actionModalContent"></div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<script>
|
| 304 |
+
const API_BASE = '/admin';
|
| 305 |
+
let currentUsers = [];
|
| 306 |
+
|
| 307 |
+
// Load initial data
|
| 308 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 309 |
+
loadStats();
|
| 310 |
+
loadUsers();
|
| 311 |
+
});
|
| 312 |
+
|
| 313 |
+
async function apiCall(endpoint, options = {}) {
|
| 314 |
+
const adminKey = localStorage.getItem('admin_key');
|
| 315 |
+
if (!adminKey) {
|
| 316 |
+
const key = prompt('Enter admin key:');
|
| 317 |
+
if (!key) return null;
|
| 318 |
+
localStorage.setItem('admin_key', key);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const response = await fetch(API_BASE + endpoint, {
|
| 322 |
+
...options,
|
| 323 |
+
headers: {
|
| 324 |
+
'Authorization': `Bearer ${localStorage.getItem('admin_key')}`,
|
| 325 |
+
'Content-Type': 'application/json',
|
| 326 |
+
...options.headers
|
| 327 |
+
}
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
if (response.status === 401) {
|
| 331 |
+
localStorage.removeItem('admin_key');
|
| 332 |
+
location.reload();
|
| 333 |
+
return null;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
return response.json();
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
async function loadStats() {
|
| 340 |
+
try {
|
| 341 |
+
const stats = await apiCall('/stats');
|
| 342 |
+
if (stats) {
|
| 343 |
+
document.getElementById('total-users').textContent = stats.total_users;
|
| 344 |
+
document.getElementById('active-users').textContent = stats.active_users;
|
| 345 |
+
document.getElementById('disabled-users').textContent = stats.disabled_users;
|
| 346 |
+
document.getElementById('total-requests').textContent = stats.usage_stats.total_requests || 0;
|
| 347 |
+
document.getElementById('total-tokens').textContent = stats.usage_stats.total_tokens || 0;
|
| 348 |
+
document.getElementById('total-cost').textContent = '$' + (stats.usage_stats.total_cost || 0).toFixed(2);
|
| 349 |
+
}
|
| 350 |
+
} catch (error) {
|
| 351 |
+
console.error('Failed to load stats:', error);
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
async function loadUsers() {
|
| 356 |
+
try {
|
| 357 |
+
const response = await apiCall('/users?limit=100');
|
| 358 |
+
if (response) {
|
| 359 |
+
currentUsers = response.users;
|
| 360 |
+
renderUserTable();
|
| 361 |
+
}
|
| 362 |
+
} catch (error) {
|
| 363 |
+
console.error('Failed to load users:', error);
|
| 364 |
+
document.getElementById('user-list').innerHTML = '<div class="error">Failed to load users</div>';
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function renderUserTable() {
|
| 369 |
+
const userList = document.getElementById('user-list');
|
| 370 |
+
|
| 371 |
+
let html = '<h3>Users</h3>';
|
| 372 |
+
html += '<table class="user-table">';
|
| 373 |
+
html += '<thead><tr>';
|
| 374 |
+
html += '<th>Token</th>';
|
| 375 |
+
html += '<th>Type</th>';
|
| 376 |
+
html += '<th>Nickname</th>';
|
| 377 |
+
html += '<th>Status</th>';
|
| 378 |
+
html += '<th>IPs</th>';
|
| 379 |
+
html += '<th>Requests</th>';
|
| 380 |
+
html += '<th>Tokens</th>';
|
| 381 |
+
html += '<th>Actions</th>';
|
| 382 |
+
html += '</tr></thead>';
|
| 383 |
+
html += '<tbody>';
|
| 384 |
+
|
| 385 |
+
currentUsers.forEach(user => {
|
| 386 |
+
const isDisabled = user.disabled_at !== null;
|
| 387 |
+
html += '<tr>';
|
| 388 |
+
html += `<td><span class="token-display">${user.token.substring(0, 8)}...</span></td>`;
|
| 389 |
+
html += `<td>${user.type}</td>`;
|
| 390 |
+
html += `<td>${user.nickname || '-'}</td>`;
|
| 391 |
+
html += `<td><span class="status ${isDisabled ? 'disabled' : 'active'}">${isDisabled ? 'Disabled' : 'Active'}</span></td>`;
|
| 392 |
+
html += `<td>${user.stats.ip_count}</td>`;
|
| 393 |
+
html += `<td>${user.stats.total_requests}</td>`;
|
| 394 |
+
html += `<td>${user.stats.total_tokens}</td>`;
|
| 395 |
+
html += '<td>';
|
| 396 |
+
|
| 397 |
+
if (isDisabled) {
|
| 398 |
+
html += `<button class="btn btn-success" onclick="enableUser('${user.token}')">Enable</button> `;
|
| 399 |
+
html += `<button class="btn btn-danger" onclick="deleteUser('${user.token}')">Delete</button>`;
|
| 400 |
+
} else {
|
| 401 |
+
html += `<button class="btn btn-danger" onclick="disableUser('${user.token}')">Disable</button> `;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
html += `<button class="btn btn-primary" onclick="rotateToken('${user.token}')">Rotate</button>`;
|
| 405 |
+
html += '</td>';
|
| 406 |
+
html += '</tr>';
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
html += '</tbody></table>';
|
| 410 |
+
userList.innerHTML = html;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
function showCreateUserModal() {
|
| 414 |
+
document.getElementById('createUserModal').style.display = 'block';
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
function closeModal(modalId) {
|
| 418 |
+
document.getElementById(modalId).style.display = 'none';
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
document.getElementById('createUserForm').addEventListener('submit', async function(e) {
|
| 422 |
+
e.preventDefault();
|
| 423 |
+
|
| 424 |
+
const formData = new FormData(e.target);
|
| 425 |
+
const data = {
|
| 426 |
+
type: formData.get('type'),
|
| 427 |
+
nickname: formData.get('nickname'),
|
| 428 |
+
token_limits: {
|
| 429 |
+
openai: parseInt(formData.get('openai_limit')),
|
| 430 |
+
anthropic: parseInt(formData.get('anthropic_limit'))
|
| 431 |
+
}
|
| 432 |
+
};
|
| 433 |
+
|
| 434 |
+
try {
|
| 435 |
+
const response = await apiCall('/users', {
|
| 436 |
+
method: 'POST',
|
| 437 |
+
body: JSON.stringify(data)
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
+
if (response && response.success) {
|
| 441 |
+
alert('User created successfully! Token: ' + response.token);
|
| 442 |
+
closeModal('createUserModal');
|
| 443 |
+
loadUsers();
|
| 444 |
+
loadStats();
|
| 445 |
+
} else {
|
| 446 |
+
alert('Error: ' + (response.error || 'Unknown error'));
|
| 447 |
+
}
|
| 448 |
+
} catch (error) {
|
| 449 |
+
alert('Error creating user: ' + error.message);
|
| 450 |
+
}
|
| 451 |
+
});
|
| 452 |
+
|
| 453 |
+
async function disableUser(token) {
|
| 454 |
+
const reason = prompt('Enter reason for disabling user:');
|
| 455 |
+
if (!reason) return;
|
| 456 |
+
|
| 457 |
+
try {
|
| 458 |
+
const response = await apiCall(`/users/${token}/disable`, {
|
| 459 |
+
method: 'POST',
|
| 460 |
+
body: JSON.stringify({ reason })
|
| 461 |
+
});
|
| 462 |
+
|
| 463 |
+
if (response && response.success) {
|
| 464 |
+
alert('User disabled successfully');
|
| 465 |
+
loadUsers();
|
| 466 |
+
loadStats();
|
| 467 |
+
} else {
|
| 468 |
+
alert('Error: ' + (response.error || 'Unknown error'));
|
| 469 |
+
}
|
| 470 |
+
} catch (error) {
|
| 471 |
+
alert('Error disabling user: ' + error.message);
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
async function enableUser(token) {
|
| 476 |
+
try {
|
| 477 |
+
const response = await apiCall(`/users/${token}/enable`, {
|
| 478 |
+
method: 'POST'
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
if (response && response.success) {
|
| 482 |
+
alert('User enabled successfully');
|
| 483 |
+
loadUsers();
|
| 484 |
+
loadStats();
|
| 485 |
+
} else {
|
| 486 |
+
alert('Error: ' + (response.error || 'Unknown error'));
|
| 487 |
+
}
|
| 488 |
+
} catch (error) {
|
| 489 |
+
alert('Error enabling user: ' + error.message);
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
async function deleteUser(token) {
|
| 494 |
+
if (!confirm('Are you sure you want to permanently delete this user? This action cannot be undone.')) {
|
| 495 |
+
return;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
try {
|
| 499 |
+
const response = await apiCall(`/users/${token}`, {
|
| 500 |
+
method: 'DELETE'
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
if (response && response.success) {
|
| 504 |
+
alert('User deleted successfully');
|
| 505 |
+
loadUsers();
|
| 506 |
+
loadStats();
|
| 507 |
+
} else {
|
| 508 |
+
alert('Error: ' + (response.error || 'Unknown error'));
|
| 509 |
+
}
|
| 510 |
+
} catch (error) {
|
| 511 |
+
alert('Error deleting user: ' + error.message);
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
async function rotateToken(token) {
|
| 516 |
+
if (!confirm('Are you sure you want to rotate this user\'s token?')) {
|
| 517 |
+
return;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
try {
|
| 521 |
+
const response = await apiCall(`/users/${token}/rotate`, {
|
| 522 |
+
method: 'POST'
|
| 523 |
+
});
|
| 524 |
+
|
| 525 |
+
if (response && response.success) {
|
| 526 |
+
alert('Token rotated successfully! New token: ' + response.new_token);
|
| 527 |
+
loadUsers();
|
| 528 |
+
} else {
|
| 529 |
+
alert('Error: ' + (response.error || 'Unknown error'));
|
| 530 |
+
}
|
| 531 |
+
} catch (error) {
|
| 532 |
+
alert('Error rotating token: ' + error.message);
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
async function refreshQuotas() {
|
| 537 |
+
if (!confirm('Are you sure you want to refresh quotas for all users?')) {
|
| 538 |
+
return;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
try {
|
| 542 |
+
const response = await apiCall('/bulk/refresh-quotas', {
|
| 543 |
+
method: 'POST'
|
| 544 |
+
});
|
| 545 |
+
|
| 546 |
+
if (response && response.success) {
|
| 547 |
+
alert(`Quotas refreshed for ${response.affected_users} users`);
|
| 548 |
+
loadUsers();
|
| 549 |
+
loadStats();
|
| 550 |
+
} else {
|
| 551 |
+
alert('Error: ' + (response.error || 'Unknown error'));
|
| 552 |
+
}
|
| 553 |
+
} catch (error) {
|
| 554 |
+
alert('Error refreshing quotas: ' + error.message);
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
async function exportUsers() {
|
| 559 |
+
try {
|
| 560 |
+
const response = await apiCall('/users?limit=1000');
|
| 561 |
+
if (response && response.users) {
|
| 562 |
+
const dataStr = JSON.stringify(response.users, null, 2);
|
| 563 |
+
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
| 564 |
+
|
| 565 |
+
const exportFileDefaultName = 'users_export.json';
|
| 566 |
+
const linkElement = document.createElement('a');
|
| 567 |
+
linkElement.setAttribute('href', dataUri);
|
| 568 |
+
linkElement.setAttribute('download', exportFileDefaultName);
|
| 569 |
+
linkElement.click();
|
| 570 |
+
}
|
| 571 |
+
} catch (error) {
|
| 572 |
+
alert('Error exporting users: ' + error.message);
|
| 573 |
+
}
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
// Close modal when clicking outside
|
| 577 |
+
window.onclick = function(event) {
|
| 578 |
+
const modals = document.getElementsByClassName('modal');
|
| 579 |
+
for (let i = 0; i < modals.length; i++) {
|
| 580 |
+
if (event.target === modals[i]) {
|
| 581 |
+
modals[i].style.display = 'none';
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
</script>
|
| 586 |
+
</body>
|
| 587 |
+
</html>
|
{templates β pages}/dashboard.html
RENAMED
|
@@ -443,7 +443,14 @@
|
|
| 443 |
|
| 444 |
<div style="background: rgba(255, 255, 255, 0.3); padding: 15px; border-radius: 10px; margin-top: 15px;">
|
| 445 |
<h4>π API Key Configuration:</h4>
|
| 446 |
-
<p><strong>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
</div>
|
| 448 |
</div>
|
| 449 |
<div class="card">
|
|
@@ -492,12 +499,12 @@
|
|
| 492 |
<!-- Token counters for each service -->
|
| 493 |
<div style="display: flex; gap: 15px; margin-bottom: 10px; flex-wrap: wrap;">
|
| 494 |
<div class="metric status-good" style="padding: 10px; margin: 0; display: inline-block; min-width: 80px;">
|
| 495 |
-
<div class="metric-value" style="font-size: 1.2em;">{{health.
|
| 496 |
-
<div class="metric-label" style="font-size: 0.8em;">
|
| 497 |
</div>
|
| 498 |
<div class="metric status-good" style="padding: 10px; margin: 0; display: inline-block; min-width: 80px;">
|
| 499 |
<div class="metric-value" style="font-size: 1.2em;">{{health.total_tokens|default(0)}}</div>
|
| 500 |
-
<div class="metric-label" style="font-size: 0.8em;">
|
| 501 |
</div>
|
| 502 |
</div>
|
| 503 |
|
|
|
|
| 443 |
|
| 444 |
<div style="background: rgba(255, 255, 255, 0.3); padding: 15px; border-radius: 10px; margin-top: 15px;">
|
| 445 |
<h4>π API Key Configuration:</h4>
|
| 446 |
+
<p><strong>Authentication Mode:</strong> {{ config.auth_display }}</p>
|
| 447 |
+
{% if config.auth_mode == 'proxy_key' %}
|
| 448 |
+
<p><strong>Usage:</strong> Use the configured proxy password as your API key</p>
|
| 449 |
+
{% elif config.auth_mode == 'user_token' %}
|
| 450 |
+
<p><strong>Usage:</strong> Each user has a unique token (contact admin for your token)</p>
|
| 451 |
+
{% elif config.auth_mode == 'none' %}
|
| 452 |
+
<p><strong>Usage:</strong> No authentication required - use any value</p>
|
| 453 |
+
{% endif %}
|
| 454 |
</div>
|
| 455 |
</div>
|
| 456 |
<div class="card">
|
|
|
|
| 499 |
<!-- Token counters for each service -->
|
| 500 |
<div style="display: flex; gap: 15px; margin-bottom: 10px; flex-wrap: wrap;">
|
| 501 |
<div class="metric status-good" style="padding: 10px; margin: 0; display: inline-block; min-width: 80px;">
|
| 502 |
+
<div class="metric-value" style="font-size: 1.2em;">{{health.successful_requests|default(0)}}</div>
|
| 503 |
+
<div class="metric-label" style="font-size: 0.8em;">Prompts</div>
|
| 504 |
</div>
|
| 505 |
<div class="metric status-good" style="padding: 10px; margin: 0; display: inline-block; min-width: 80px;">
|
| 506 |
<div class="metric-value" style="font-size: 1.2em;">{{health.total_tokens|default(0)}}</div>
|
| 507 |
+
<div class="metric-label" style="font-size: 0.8em;">Tokens</div>
|
| 508 |
</div>
|
| 509 |
</div>
|
| 510 |
|
requirements.txt
CHANGED
|
@@ -2,4 +2,6 @@ Flask==2.3.3
|
|
| 2 |
requests==2.31.0
|
| 3 |
gunicorn==21.2.0
|
| 4 |
python-dotenv==1.0.0
|
| 5 |
-
tiktoken==0.5.2
|
|
|
|
|
|
|
|
|
| 2 |
requests==2.31.0
|
| 3 |
gunicorn==21.2.0
|
| 4 |
python-dotenv==1.0.0
|
| 5 |
+
tiktoken==0.5.2
|
| 6 |
+
firebase-admin==6.3.0
|
| 7 |
+
Flask-WTF==1.1.1
|
run.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Startup script for NyanProxy
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Add the project root to Python path so imports work correctly
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
+
|
| 11 |
+
# Run the application
|
| 12 |
+
if __name__ == '__main__':
|
| 13 |
+
from core.app import app
|
| 14 |
+
|
| 15 |
+
port = int(os.getenv('PORT', 7860))
|
| 16 |
+
debug_mode = os.getenv('DEBUG', 'False').lower() == 'true'
|
| 17 |
+
|
| 18 |
+
if debug_mode:
|
| 19 |
+
# Development mode with debugging
|
| 20 |
+
app.run(host='0.0.0.0', port=port, debug=True, threaded=True)
|
| 21 |
+
else:
|
| 22 |
+
# Production mode with threading and better performance
|
| 23 |
+
app.run(
|
| 24 |
+
host='0.0.0.0',
|
| 25 |
+
port=port,
|
| 26 |
+
debug=False,
|
| 27 |
+
threaded=True,
|
| 28 |
+
processes=1,
|
| 29 |
+
use_reloader=False
|
| 30 |
+
)
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# NyanProxy authentication and user management system
|
src/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Configuration module
|
src/config/auth.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
from typing import Optional, Literal
|
| 4 |
+
|
| 5 |
+
AuthMode = Literal["none", "proxy_key", "user_token"]
|
| 6 |
+
|
| 7 |
+
class AuthConfig:
|
| 8 |
+
"""Authentication configuration with environment variable support"""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.mode: AuthMode = os.getenv('AUTH_MODE', 'user_token').lower()
|
| 12 |
+
self.proxy_password: Optional[str] = os.getenv('PROXY_PASSWORD')
|
| 13 |
+
self.admin_key: Optional[str] = os.getenv('ADMIN_KEY')
|
| 14 |
+
|
| 15 |
+
# User token settings
|
| 16 |
+
self.max_ips_per_user: int = int(os.getenv('MAX_IPS_PER_USER', '3'))
|
| 17 |
+
self.max_ips_auto_ban: bool = os.getenv('MAX_IPS_AUTO_BAN', 'true').lower() == 'true'
|
| 18 |
+
|
| 19 |
+
# Rate limiting
|
| 20 |
+
self.rate_limit_per_minute: int = int(os.getenv('RATE_LIMIT_PER_MINUTE', '60'))
|
| 21 |
+
self.rate_limit_enabled: bool = os.getenv('RATE_LIMIT_ENABLED', 'true').lower() == 'true'
|
| 22 |
+
|
| 23 |
+
# Firebase configuration
|
| 24 |
+
self.firebase_url: Optional[str] = os.getenv('FIREBASE_URL')
|
| 25 |
+
self.firebase_service_account_key: Optional[str] = self._get_firebase_service_account_key()
|
| 26 |
+
|
| 27 |
+
# Validate configuration
|
| 28 |
+
self._validate_config()
|
| 29 |
+
|
| 30 |
+
def _get_firebase_service_account_key(self) -> Optional[str]:
|
| 31 |
+
"""Get Firebase service account key, supporting both JSON and Base64 formats"""
|
| 32 |
+
key = os.getenv('FIREBASE_SERVICE_ACCOUNT_KEY')
|
| 33 |
+
if not key:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
# Check if it's base64 encoded (try to decode)
|
| 37 |
+
try:
|
| 38 |
+
# Try to decode as base64 first
|
| 39 |
+
decoded = base64.b64decode(key).decode('utf-8')
|
| 40 |
+
# If successful and looks like JSON, return it
|
| 41 |
+
if decoded.strip().startswith('{'):
|
| 42 |
+
print(f"Decoding Base64 key (first 50 chars): {key[:50]}...")
|
| 43 |
+
print(f"Decoded successfully (first 100 chars): {decoded[:100]}...")
|
| 44 |
+
return decoded
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"Base64 decode error: {e}")
|
| 47 |
+
|
| 48 |
+
# Return as-is (assume it's already JSON)
|
| 49 |
+
print(f"Using key as-is (first 50 chars): {key[:50]}...")
|
| 50 |
+
return key
|
| 51 |
+
|
| 52 |
+
def _validate_config(self):
|
| 53 |
+
"""Validate authentication configuration"""
|
| 54 |
+
if self.mode not in ["none", "proxy_key", "user_token"]:
|
| 55 |
+
raise ValueError(f"Invalid AUTH_MODE: {self.mode}. Must be 'none', 'proxy_key', or 'user_token'")
|
| 56 |
+
|
| 57 |
+
if self.mode == "proxy_key" and not self.proxy_password:
|
| 58 |
+
raise ValueError("PROXY_PASSWORD is required when AUTH_MODE is 'proxy_key'")
|
| 59 |
+
|
| 60 |
+
if self.mode == "user_token" and not self.firebase_url:
|
| 61 |
+
print("Warning: Firebase URL not configured. User tokens will be stored in memory only.")
|
| 62 |
+
|
| 63 |
+
def is_auth_required(self) -> bool:
|
| 64 |
+
"""Check if authentication is required"""
|
| 65 |
+
return self.mode in ["proxy_key", "user_token"]
|
| 66 |
+
|
| 67 |
+
def is_user_token_mode(self) -> bool:
|
| 68 |
+
"""Check if user token mode is enabled"""
|
| 69 |
+
return self.mode == "user_token"
|
| 70 |
+
|
| 71 |
+
def is_proxy_key_mode(self) -> bool:
|
| 72 |
+
"""Check if proxy key mode is enabled"""
|
| 73 |
+
return self.mode == "proxy_key"
|
| 74 |
+
|
| 75 |
+
# Global config instance
|
| 76 |
+
auth_config = AuthConfig()
|
src/middleware/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Middleware module
|
src/middleware/auth.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import request, jsonify, g, session, redirect, url_for
|
| 2 |
+
from functools import wraps
|
| 3 |
+
from typing import Optional, Tuple
|
| 4 |
+
import time
|
| 5 |
+
import hashlib
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
import secrets
|
| 8 |
+
|
| 9 |
+
from ..config.auth import auth_config
|
| 10 |
+
from ..services.user_store import user_store, AuthResult
|
| 11 |
+
|
| 12 |
+
# Rate limiting storage
|
| 13 |
+
rate_limit_store = defaultdict(list)
|
| 14 |
+
|
| 15 |
+
def extract_token_from_request() -> Optional[str]:
|
| 16 |
+
"""Extract authentication token from request headers"""
|
| 17 |
+
# Check Authorization header (Bearer token)
|
| 18 |
+
auth_header = request.headers.get('Authorization')
|
| 19 |
+
if auth_header and auth_header.startswith('Bearer '):
|
| 20 |
+
return auth_header[7:] # Remove "Bearer " prefix
|
| 21 |
+
|
| 22 |
+
# Check x-api-key header
|
| 23 |
+
api_key = request.headers.get('x-api-key')
|
| 24 |
+
if api_key:
|
| 25 |
+
return api_key
|
| 26 |
+
|
| 27 |
+
# Check key query parameter
|
| 28 |
+
key_param = request.args.get('key')
|
| 29 |
+
if key_param:
|
| 30 |
+
return key_param
|
| 31 |
+
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
def get_client_ip() -> str:
|
| 35 |
+
"""Get client IP address from request"""
|
| 36 |
+
# Check X-Forwarded-For header (for proxies)
|
| 37 |
+
forwarded_for = request.headers.get('X-Forwarded-For')
|
| 38 |
+
if forwarded_for:
|
| 39 |
+
return forwarded_for.split(',')[0].strip()
|
| 40 |
+
|
| 41 |
+
# Check X-Real-IP header (for nginx)
|
| 42 |
+
real_ip = request.headers.get('X-Real-IP')
|
| 43 |
+
if real_ip:
|
| 44 |
+
return real_ip
|
| 45 |
+
|
| 46 |
+
# Fallback to remote_addr
|
| 47 |
+
return request.remote_addr or '127.0.0.1'
|
| 48 |
+
|
| 49 |
+
def check_rate_limit(identifier: str, limit_per_minute: int = None) -> Tuple[bool, int]:
|
| 50 |
+
"""Check if identifier is within rate limit. Returns (allowed, remaining)"""
|
| 51 |
+
if not auth_config.rate_limit_enabled:
|
| 52 |
+
return True, limit_per_minute or auth_config.rate_limit_per_minute
|
| 53 |
+
|
| 54 |
+
if limit_per_minute is None:
|
| 55 |
+
limit_per_minute = auth_config.rate_limit_per_minute
|
| 56 |
+
|
| 57 |
+
current_time = time.time()
|
| 58 |
+
minute_ago = current_time - 60
|
| 59 |
+
|
| 60 |
+
# Clean old entries
|
| 61 |
+
rate_limit_store[identifier] = [
|
| 62 |
+
timestamp for timestamp in rate_limit_store[identifier]
|
| 63 |
+
if timestamp > minute_ago
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
# Check if under limit
|
| 67 |
+
current_count = len(rate_limit_store[identifier])
|
| 68 |
+
if current_count >= limit_per_minute:
|
| 69 |
+
return False, 0
|
| 70 |
+
|
| 71 |
+
# Add current request
|
| 72 |
+
rate_limit_store[identifier].append(current_time)
|
| 73 |
+
return True, limit_per_minute - current_count - 1
|
| 74 |
+
|
| 75 |
+
def authenticate_request() -> Tuple[bool, Optional[str], Optional[dict]]:
|
| 76 |
+
"""
|
| 77 |
+
Authenticate request based on auth mode.
|
| 78 |
+
Returns (success, error_message, user_data)
|
| 79 |
+
"""
|
| 80 |
+
client_ip = get_client_ip()
|
| 81 |
+
|
| 82 |
+
# Check authentication mode
|
| 83 |
+
if auth_config.mode == "none":
|
| 84 |
+
# No authentication required
|
| 85 |
+
return True, None, {"type": "none", "ip": client_ip}
|
| 86 |
+
|
| 87 |
+
elif auth_config.mode == "proxy_key":
|
| 88 |
+
# Single proxy password authentication
|
| 89 |
+
token = extract_token_from_request()
|
| 90 |
+
|
| 91 |
+
if not token:
|
| 92 |
+
return False, "Authentication required: provide API key", None
|
| 93 |
+
|
| 94 |
+
if token != auth_config.proxy_password:
|
| 95 |
+
return False, "Invalid API key", None
|
| 96 |
+
|
| 97 |
+
# Check rate limit by IP for proxy key mode
|
| 98 |
+
allowed, remaining = check_rate_limit(client_ip)
|
| 99 |
+
if not allowed:
|
| 100 |
+
return False, "Rate limit exceeded", None
|
| 101 |
+
|
| 102 |
+
return True, None, {
|
| 103 |
+
"type": "proxy_key",
|
| 104 |
+
"ip": client_ip,
|
| 105 |
+
"rate_limit_remaining": remaining
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
elif auth_config.mode == "user_token":
|
| 109 |
+
# Individual user token authentication
|
| 110 |
+
token = extract_token_from_request()
|
| 111 |
+
|
| 112 |
+
if not token:
|
| 113 |
+
return False, "Authentication required: provide user token", None
|
| 114 |
+
|
| 115 |
+
# Authenticate with user store
|
| 116 |
+
auth_result, user = user_store.authenticate(token, client_ip)
|
| 117 |
+
|
| 118 |
+
if auth_result == AuthResult.NOT_FOUND:
|
| 119 |
+
return False, "Invalid user token", None
|
| 120 |
+
|
| 121 |
+
elif auth_result == AuthResult.DISABLED:
|
| 122 |
+
reason = user.disabled_reason or "Account disabled"
|
| 123 |
+
return False, f"Account disabled: {reason}", None
|
| 124 |
+
|
| 125 |
+
elif auth_result == AuthResult.LIMITED:
|
| 126 |
+
return False, "IP address limit exceeded", None
|
| 127 |
+
|
| 128 |
+
elif auth_result == AuthResult.SUCCESS:
|
| 129 |
+
# Check rate limit by user token
|
| 130 |
+
allowed, remaining = check_rate_limit(token)
|
| 131 |
+
if not allowed:
|
| 132 |
+
return False, "Rate limit exceeded", None
|
| 133 |
+
|
| 134 |
+
return True, None, {
|
| 135 |
+
"type": "user_token",
|
| 136 |
+
"token": token,
|
| 137 |
+
"user": user,
|
| 138 |
+
"ip": client_ip,
|
| 139 |
+
"rate_limit_remaining": remaining
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
return False, "Invalid authentication mode", None
|
| 143 |
+
|
| 144 |
+
def require_auth(f):
|
| 145 |
+
"""Decorator to require authentication for endpoints"""
|
| 146 |
+
@wraps(f)
|
| 147 |
+
def decorated_function(*args, **kwargs):
|
| 148 |
+
success, error_message, user_data = authenticate_request()
|
| 149 |
+
|
| 150 |
+
if not success:
|
| 151 |
+
return jsonify({"error": error_message}), 401
|
| 152 |
+
|
| 153 |
+
# Store auth data in Flask g object for use in endpoint
|
| 154 |
+
g.auth_data = user_data
|
| 155 |
+
|
| 156 |
+
return f(*args, **kwargs)
|
| 157 |
+
|
| 158 |
+
return decorated_function
|
| 159 |
+
|
| 160 |
+
def require_admin_auth(f):
|
| 161 |
+
"""Decorator to require admin authentication"""
|
| 162 |
+
@wraps(f)
|
| 163 |
+
def decorated_function(*args, **kwargs):
|
| 164 |
+
# Check for admin key in Authorization header
|
| 165 |
+
auth_header = request.headers.get('Authorization')
|
| 166 |
+
if not auth_header or not auth_header.startswith('Bearer '):
|
| 167 |
+
return jsonify({"error": "Admin authentication required"}), 401
|
| 168 |
+
|
| 169 |
+
admin_key = auth_header[7:] # Remove "Bearer " prefix
|
| 170 |
+
|
| 171 |
+
if admin_key != auth_config.admin_key:
|
| 172 |
+
return jsonify({"error": "Invalid admin key"}), 401
|
| 173 |
+
|
| 174 |
+
return f(*args, **kwargs)
|
| 175 |
+
|
| 176 |
+
return decorated_function
|
| 177 |
+
|
| 178 |
+
def check_quota(model_family: str) -> Tuple[bool, Optional[str]]:
|
| 179 |
+
"""Check if current user has quota for model family"""
|
| 180 |
+
if not hasattr(g, 'auth_data') or g.auth_data["type"] != "user_token":
|
| 181 |
+
return True, None # No quota limits for non-user-token auth
|
| 182 |
+
|
| 183 |
+
token = g.auth_data["token"]
|
| 184 |
+
has_quota, used, limit = user_store.check_quota(token, model_family)
|
| 185 |
+
|
| 186 |
+
if not has_quota:
|
| 187 |
+
return False, f"Quota exceeded for {model_family}: {used}/{limit} tokens used"
|
| 188 |
+
|
| 189 |
+
return True, None
|
| 190 |
+
|
| 191 |
+
def track_token_usage(model_family: str, input_tokens: int, output_tokens: int, cost: float = 0.0, response_time_ms: float = 0.0):
|
| 192 |
+
"""Track token usage for current user with enhanced tracking"""
|
| 193 |
+
if not hasattr(g, 'auth_data') or g.auth_data["type"] != "user_token":
|
| 194 |
+
return # No tracking for non-user-token auth
|
| 195 |
+
|
| 196 |
+
token = g.auth_data["token"]
|
| 197 |
+
user = g.auth_data["user"]
|
| 198 |
+
ip_hash = hashlib.sha256(g.auth_data["ip"].encode()).hexdigest()
|
| 199 |
+
user_agent = request.headers.get('User-Agent', '')
|
| 200 |
+
|
| 201 |
+
# Use the enhanced tracking method
|
| 202 |
+
user.add_request_tracking(
|
| 203 |
+
model_family=model_family,
|
| 204 |
+
input_tokens=input_tokens,
|
| 205 |
+
output_tokens=output_tokens,
|
| 206 |
+
cost=cost,
|
| 207 |
+
ip_hash=ip_hash,
|
| 208 |
+
user_agent=user_agent
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Also use the new structured event logger
|
| 212 |
+
from ..services.structured_event_logger import structured_logger
|
| 213 |
+
structured_logger.log_chat_completion(
|
| 214 |
+
user_token=token,
|
| 215 |
+
model_family=model_family,
|
| 216 |
+
model_name=f"{model_family}-model",
|
| 217 |
+
input_tokens=input_tokens,
|
| 218 |
+
output_tokens=output_tokens,
|
| 219 |
+
cost_usd=cost,
|
| 220 |
+
response_time_ms=response_time_ms,
|
| 221 |
+
success=True,
|
| 222 |
+
ip_hash=ip_hash,
|
| 223 |
+
user_agent=user_agent
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Mark user for Firebase sync
|
| 227 |
+
user_store.flush_queue.add(token)
|
| 228 |
+
|
| 229 |
+
def require_admin_session(f):
|
| 230 |
+
"""Decorator to require admin session authentication for web interface"""
|
| 231 |
+
@wraps(f)
|
| 232 |
+
def decorated_function(*args, **kwargs):
|
| 233 |
+
if not session.get('admin_authenticated'):
|
| 234 |
+
return redirect(url_for('admin.login'))
|
| 235 |
+
return f(*args, **kwargs)
|
| 236 |
+
return decorated_function
|
| 237 |
+
|
| 238 |
+
def generate_csrf_token():
|
| 239 |
+
"""Generate a CSRF token"""
|
| 240 |
+
if 'csrf_token' not in session:
|
| 241 |
+
session['csrf_token'] = secrets.token_urlsafe(32)
|
| 242 |
+
return session['csrf_token']
|
| 243 |
+
|
| 244 |
+
def validate_csrf_token(token):
|
| 245 |
+
"""Validate CSRF token"""
|
| 246 |
+
return token and token == session.get('csrf_token')
|
| 247 |
+
|
| 248 |
+
def csrf_protect(f):
|
| 249 |
+
"""Decorator to protect against CSRF attacks"""
|
| 250 |
+
@wraps(f)
|
| 251 |
+
def decorated_function(*args, **kwargs):
|
| 252 |
+
if request.method == 'POST':
|
| 253 |
+
token = request.form.get('_csrf') or request.headers.get('X-CSRFToken')
|
| 254 |
+
if not validate_csrf_token(token):
|
| 255 |
+
return jsonify({'error': 'CSRF token validation failed'}), 403
|
| 256 |
+
return f(*args, **kwargs)
|
| 257 |
+
return decorated_function
|
src/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routes module
|
src/routes/admin.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from ..middleware.auth import require_admin_auth
|
| 6 |
+
from ..services.user_store import user_store, UserType
|
| 7 |
+
from ..services.event_logger import event_logger
|
| 8 |
+
|
| 9 |
+
admin_bp = Blueprint('admin_api', __name__, url_prefix='/admin/api')
|
| 10 |
+
|
| 11 |
+
@admin_bp.route('/users', methods=['GET'])
|
| 12 |
+
@require_admin_auth
|
| 13 |
+
def list_users():
|
| 14 |
+
"""List all users with pagination and filtering"""
|
| 15 |
+
page = int(request.args.get('page', 1))
|
| 16 |
+
limit = int(request.args.get('limit', 50))
|
| 17 |
+
user_type = request.args.get('type')
|
| 18 |
+
search = request.args.get('search', '').lower()
|
| 19 |
+
|
| 20 |
+
all_users = user_store.get_all_users()
|
| 21 |
+
|
| 22 |
+
# Apply filters
|
| 23 |
+
filtered_users = []
|
| 24 |
+
for user in all_users:
|
| 25 |
+
if user_type and user.type.value != user_type:
|
| 26 |
+
continue
|
| 27 |
+
|
| 28 |
+
if search:
|
| 29 |
+
if (search in user.token.lower() or
|
| 30 |
+
(user.nickname and search in user.nickname.lower()) or
|
| 31 |
+
(user.disabled_reason and search in user.disabled_reason.lower())):
|
| 32 |
+
filtered_users.append(user)
|
| 33 |
+
else:
|
| 34 |
+
filtered_users.append(user)
|
| 35 |
+
|
| 36 |
+
# Pagination
|
| 37 |
+
start = (page - 1) * limit
|
| 38 |
+
end = start + limit
|
| 39 |
+
paginated_users = filtered_users[start:end]
|
| 40 |
+
|
| 41 |
+
# Convert to dict with additional stats
|
| 42 |
+
users_data = []
|
| 43 |
+
for user in paginated_users:
|
| 44 |
+
user_dict = user.to_dict()
|
| 45 |
+
|
| 46 |
+
# Add usage statistics
|
| 47 |
+
total_tokens = sum(count.total for count in user.token_counts.values())
|
| 48 |
+
total_requests = sum(usage.prompt_count for usage in user.ip_usage)
|
| 49 |
+
|
| 50 |
+
user_dict['stats'] = {
|
| 51 |
+
'total_tokens': total_tokens,
|
| 52 |
+
'total_requests': total_requests,
|
| 53 |
+
'ip_count': len(user.ip),
|
| 54 |
+
'days_since_created': (datetime.now() - user.created_at).days
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
users_data.append(user_dict)
|
| 58 |
+
|
| 59 |
+
return jsonify({
|
| 60 |
+
'users': users_data,
|
| 61 |
+
'pagination': {
|
| 62 |
+
'page': page,
|
| 63 |
+
'limit': limit,
|
| 64 |
+
'total': len(filtered_users),
|
| 65 |
+
'has_next': end < len(filtered_users)
|
| 66 |
+
}
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
@admin_bp.route('/users', methods=['POST'])
|
| 70 |
+
@require_admin_auth
|
| 71 |
+
def create_user():
|
| 72 |
+
"""Create a new user"""
|
| 73 |
+
data = request.get_json()
|
| 74 |
+
|
| 75 |
+
user_type = UserType(data.get('type', 'normal'))
|
| 76 |
+
nickname = data.get('nickname')
|
| 77 |
+
token_limits = data.get('token_limits', {})
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
token = user_store.create_user(
|
| 81 |
+
user_type=user_type,
|
| 82 |
+
token_limits=token_limits,
|
| 83 |
+
nickname=nickname
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Log admin action
|
| 87 |
+
event_logger.log_user_action(
|
| 88 |
+
token=token,
|
| 89 |
+
action='user_created',
|
| 90 |
+
details={
|
| 91 |
+
'created_by': 'admin',
|
| 92 |
+
'type': user_type.value,
|
| 93 |
+
'nickname': nickname
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return jsonify({
|
| 98 |
+
'success': True,
|
| 99 |
+
'token': token,
|
| 100 |
+
'message': 'User created successfully'
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
return jsonify({
|
| 105 |
+
'success': False,
|
| 106 |
+
'error': str(e)
|
| 107 |
+
}), 500
|
| 108 |
+
|
| 109 |
+
@admin_bp.route('/users/<token>', methods=['GET'])
|
| 110 |
+
@require_admin_auth
|
| 111 |
+
def get_user(token: str):
|
| 112 |
+
"""Get detailed user information"""
|
| 113 |
+
user = user_store.get_user(token)
|
| 114 |
+
|
| 115 |
+
if not user:
|
| 116 |
+
return jsonify({'error': 'User not found'}), 404
|
| 117 |
+
|
| 118 |
+
user_dict = user.to_dict()
|
| 119 |
+
|
| 120 |
+
# Add detailed statistics
|
| 121 |
+
usage_stats = event_logger.get_usage_stats(token)
|
| 122 |
+
user_dict['usage_stats'] = usage_stats
|
| 123 |
+
|
| 124 |
+
# Add recent events
|
| 125 |
+
recent_events = event_logger.get_events(token=token, limit=10)
|
| 126 |
+
user_dict['recent_events'] = recent_events
|
| 127 |
+
|
| 128 |
+
return jsonify(user_dict)
|
| 129 |
+
|
| 130 |
+
@admin_bp.route('/users/<token>', methods=['PUT'])
|
| 131 |
+
@require_admin_auth
|
| 132 |
+
def update_user(token: str):
|
| 133 |
+
"""Update user properties"""
|
| 134 |
+
user = user_store.get_user(token)
|
| 135 |
+
|
| 136 |
+
if not user:
|
| 137 |
+
return jsonify({'error': 'User not found'}), 404
|
| 138 |
+
|
| 139 |
+
data = request.get_json()
|
| 140 |
+
|
| 141 |
+
# Update nickname
|
| 142 |
+
if 'nickname' in data:
|
| 143 |
+
user.nickname = data['nickname']
|
| 144 |
+
|
| 145 |
+
# Update token limits
|
| 146 |
+
if 'token_limits' in data:
|
| 147 |
+
user.token_limits.update(data['token_limits'])
|
| 148 |
+
|
| 149 |
+
# Update user type
|
| 150 |
+
if 'type' in data:
|
| 151 |
+
user.type = UserType(data['type'])
|
| 152 |
+
|
| 153 |
+
# Add to flush queue
|
| 154 |
+
user_store.flush_queue.add(token)
|
| 155 |
+
|
| 156 |
+
# Log admin action
|
| 157 |
+
event_logger.log_user_action(
|
| 158 |
+
token=token,
|
| 159 |
+
action='user_updated',
|
| 160 |
+
details={
|
| 161 |
+
'updated_by': 'admin',
|
| 162 |
+
'changes': data
|
| 163 |
+
}
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
return jsonify({
|
| 167 |
+
'success': True,
|
| 168 |
+
'message': 'User updated successfully'
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
@admin_bp.route('/users/<token>/disable', methods=['POST'])
|
| 172 |
+
@require_admin_auth
|
| 173 |
+
def disable_user(token: str):
|
| 174 |
+
"""Disable/ban a user"""
|
| 175 |
+
data = request.get_json() or {}
|
| 176 |
+
reason = data.get('reason', 'Disabled by admin')
|
| 177 |
+
|
| 178 |
+
success = user_store.disable_user(token, reason)
|
| 179 |
+
|
| 180 |
+
if not success:
|
| 181 |
+
return jsonify({'error': 'User not found'}), 404
|
| 182 |
+
|
| 183 |
+
# Log admin action
|
| 184 |
+
event_logger.log_user_action(
|
| 185 |
+
token=token,
|
| 186 |
+
action='user_disabled',
|
| 187 |
+
details={
|
| 188 |
+
'disabled_by': 'admin',
|
| 189 |
+
'reason': reason
|
| 190 |
+
}
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
return jsonify({
|
| 194 |
+
'success': True,
|
| 195 |
+
'message': 'User disabled successfully'
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
@admin_bp.route('/users/<token>/enable', methods=['POST'])
|
| 199 |
+
@require_admin_auth
|
| 200 |
+
def enable_user(token: str):
|
| 201 |
+
"""Enable/unban a user"""
|
| 202 |
+
success = user_store.reactivate_user(token)
|
| 203 |
+
|
| 204 |
+
if not success:
|
| 205 |
+
return jsonify({'error': 'User not found'}), 404
|
| 206 |
+
|
| 207 |
+
# Log admin action
|
| 208 |
+
event_logger.log_user_action(
|
| 209 |
+
token=token,
|
| 210 |
+
action='user_enabled',
|
| 211 |
+
details={
|
| 212 |
+
'enabled_by': 'admin'
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
return jsonify({
|
| 217 |
+
'success': True,
|
| 218 |
+
'message': 'User enabled successfully'
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
@admin_bp.route('/users/<token>/rotate', methods=['POST'])
|
| 222 |
+
@require_admin_auth
|
| 223 |
+
def rotate_user_token(token: str):
|
| 224 |
+
"""Rotate user token"""
|
| 225 |
+
new_token = user_store.rotate_user_token(token)
|
| 226 |
+
|
| 227 |
+
if not new_token:
|
| 228 |
+
return jsonify({'error': 'User not found'}), 404
|
| 229 |
+
|
| 230 |
+
# Log admin action
|
| 231 |
+
event_logger.log_user_action(
|
| 232 |
+
token=new_token,
|
| 233 |
+
action='token_rotated',
|
| 234 |
+
details={
|
| 235 |
+
'rotated_by': 'admin',
|
| 236 |
+
'old_token': token[:8] + '...'
|
| 237 |
+
}
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
return jsonify({
|
| 241 |
+
'success': True,
|
| 242 |
+
'new_token': new_token,
|
| 243 |
+
'message': 'Token rotated successfully'
|
| 244 |
+
})
|
| 245 |
+
|
| 246 |
+
@admin_bp.route('/users/<token>', methods=['DELETE'])
|
| 247 |
+
@require_admin_auth
|
| 248 |
+
def delete_user(token: str):
|
| 249 |
+
"""Delete a user (automatically disables first if needed)"""
|
| 250 |
+
try:
|
| 251 |
+
user = user_store.get_user(token)
|
| 252 |
+
if not user:
|
| 253 |
+
return jsonify({'error': 'User not found'}), 404
|
| 254 |
+
|
| 255 |
+
# If user is not disabled, disable them first
|
| 256 |
+
if not user.is_disabled():
|
| 257 |
+
user_store.disable_user(token, "Disabled before deletion by admin")
|
| 258 |
+
|
| 259 |
+
# Log disable action
|
| 260 |
+
event_logger.log_user_action(
|
| 261 |
+
token=token,
|
| 262 |
+
action='user_disabled',
|
| 263 |
+
details={
|
| 264 |
+
'disabled_by': 'admin',
|
| 265 |
+
'reason': 'Disabled before deletion by admin'
|
| 266 |
+
}
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
# Now delete the user
|
| 270 |
+
success = user_store.delete_user(token)
|
| 271 |
+
|
| 272 |
+
if not success:
|
| 273 |
+
return jsonify({'error': 'Failed to delete user'}), 500
|
| 274 |
+
|
| 275 |
+
# Log admin action
|
| 276 |
+
event_logger.log_user_action(
|
| 277 |
+
token=token,
|
| 278 |
+
action='user_deleted',
|
| 279 |
+
details={
|
| 280 |
+
'deleted_by': 'admin'
|
| 281 |
+
}
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
return jsonify({
|
| 285 |
+
'success': True,
|
| 286 |
+
'message': 'User deleted successfully'
|
| 287 |
+
})
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
return jsonify({
|
| 291 |
+
'success': False,
|
| 292 |
+
'error': f'Failed to delete user: {str(e)}'
|
| 293 |
+
}), 500
|
| 294 |
+
|
| 295 |
+
@admin_bp.route('/stats', methods=['GET'])
|
| 296 |
+
@require_admin_auth
|
| 297 |
+
def get_admin_stats():
|
| 298 |
+
"""Get overall system statistics"""
|
| 299 |
+
all_users = user_store.get_all_users()
|
| 300 |
+
|
| 301 |
+
stats = {
|
| 302 |
+
'total_users': len(all_users),
|
| 303 |
+
'active_users': len([u for u in all_users if not u.is_disabled()]),
|
| 304 |
+
'disabled_users': len([u for u in all_users if u.is_disabled()]),
|
| 305 |
+
'users_by_type': {
|
| 306 |
+
'normal': len([u for u in all_users if u.type == UserType.NORMAL]),
|
| 307 |
+
'special': len([u for u in all_users if u.type == UserType.SPECIAL]),
|
| 308 |
+
'temporary': len([u for u in all_users if u.type == UserType.TEMPORARY])
|
| 309 |
+
},
|
| 310 |
+
'usage_stats': event_logger.get_usage_stats(),
|
| 311 |
+
'event_counts': event_logger.get_event_counts()
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
return jsonify(stats)
|
| 315 |
+
|
| 316 |
+
@admin_bp.route('/events', methods=['GET'])
|
| 317 |
+
@require_admin_auth
|
| 318 |
+
def get_events():
|
| 319 |
+
"""Get system events with filtering"""
|
| 320 |
+
token = request.args.get('token')
|
| 321 |
+
event_type = request.args.get('type')
|
| 322 |
+
limit = int(request.args.get('limit', 100))
|
| 323 |
+
offset = int(request.args.get('offset', 0))
|
| 324 |
+
|
| 325 |
+
events = event_logger.get_events(
|
| 326 |
+
token=token,
|
| 327 |
+
event_type=event_type,
|
| 328 |
+
limit=limit,
|
| 329 |
+
offset=offset
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
return jsonify({
|
| 333 |
+
'events': events,
|
| 334 |
+
'pagination': {
|
| 335 |
+
'limit': limit,
|
| 336 |
+
'offset': offset,
|
| 337 |
+
'has_more': len(events) == limit
|
| 338 |
+
}
|
| 339 |
+
})
|
| 340 |
+
|
| 341 |
+
@admin_bp.route('/bulk/refresh-quotas', methods=['POST'])
|
| 342 |
+
@require_admin_auth
|
| 343 |
+
def bulk_refresh_quotas():
|
| 344 |
+
"""Refresh quotas for all users"""
|
| 345 |
+
data = request.get_json() or {}
|
| 346 |
+
model_family = data.get('model_family', 'all')
|
| 347 |
+
|
| 348 |
+
affected_users = 0
|
| 349 |
+
|
| 350 |
+
for user in user_store.get_all_users():
|
| 351 |
+
if user.is_disabled():
|
| 352 |
+
continue
|
| 353 |
+
|
| 354 |
+
# Reset token counts
|
| 355 |
+
if model_family == 'all':
|
| 356 |
+
for family in user.token_counts:
|
| 357 |
+
user.token_counts[family] = type(user.token_counts[family])()
|
| 358 |
+
elif model_family in user.token_counts:
|
| 359 |
+
user.token_counts[model_family] = type(user.token_counts[model_family])()
|
| 360 |
+
|
| 361 |
+
user_store.flush_queue.add(user.token)
|
| 362 |
+
affected_users += 1
|
| 363 |
+
|
| 364 |
+
# Log admin action
|
| 365 |
+
event_logger.log_user_action(
|
| 366 |
+
token='admin',
|
| 367 |
+
action='bulk_refresh_quotas',
|
| 368 |
+
details={
|
| 369 |
+
'performed_by': 'admin',
|
| 370 |
+
'model_family': model_family,
|
| 371 |
+
'affected_users': affected_users
|
| 372 |
+
}
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
return jsonify({
|
| 376 |
+
'success': True,
|
| 377 |
+
'affected_users': affected_users,
|
| 378 |
+
'message': f'Refreshed quotas for {affected_users} users'
|
| 379 |
+
})
|
src/routes/admin_web.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify, render_template, redirect, url_for, session, flash
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
from ..middleware.auth import require_admin_auth, require_admin_session
|
| 7 |
+
from ..services.user_store import user_store, UserType
|
| 8 |
+
from ..services.event_logger import event_logger
|
| 9 |
+
from ..services.structured_event_logger import structured_logger
|
| 10 |
+
from ..config.auth import auth_config
|
| 11 |
+
|
| 12 |
+
admin_web_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
| 13 |
+
|
| 14 |
+
@admin_web_bp.route('/login', methods=['GET', 'POST'])
|
| 15 |
+
def login():
|
| 16 |
+
"""Admin login page"""
|
| 17 |
+
if request.method == 'POST':
|
| 18 |
+
admin_key = request.form.get('admin_key')
|
| 19 |
+
|
| 20 |
+
if admin_key == auth_config.admin_key:
|
| 21 |
+
session['admin_authenticated'] = True
|
| 22 |
+
session.permanent = True
|
| 23 |
+
flash('Successfully logged in', 'success')
|
| 24 |
+
return redirect(url_for('admin.dashboard'))
|
| 25 |
+
else:
|
| 26 |
+
flash('Invalid admin key', 'error')
|
| 27 |
+
|
| 28 |
+
dashboard_config = {
|
| 29 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 30 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return render_template('admin/login.html', config=dashboard_config)
|
| 34 |
+
|
| 35 |
+
@admin_web_bp.route('/logout')
|
| 36 |
+
def logout():
|
| 37 |
+
"""Admin logout"""
|
| 38 |
+
session.pop('admin_authenticated', None)
|
| 39 |
+
flash('Successfully logged out', 'info')
|
| 40 |
+
return redirect(url_for('admin.login'))
|
| 41 |
+
|
| 42 |
+
@admin_web_bp.route('/')
|
| 43 |
+
@require_admin_session
|
| 44 |
+
def dashboard():
|
| 45 |
+
"""Admin dashboard"""
|
| 46 |
+
# Get system statistics
|
| 47 |
+
all_users = user_store.get_all_users()
|
| 48 |
+
|
| 49 |
+
stats = {
|
| 50 |
+
'total_users': len(all_users),
|
| 51 |
+
'active_users': len([u for u in all_users if not u.is_disabled()]),
|
| 52 |
+
'disabled_users': len([u for u in all_users if u.is_disabled()]),
|
| 53 |
+
'users_by_type': {
|
| 54 |
+
'normal': len([u for u in all_users if u.type == UserType.NORMAL]),
|
| 55 |
+
'special': len([u for u in all_users if u.type == UserType.SPECIAL]),
|
| 56 |
+
'temporary': len([u for u in all_users if u.type == UserType.TEMPORARY])
|
| 57 |
+
},
|
| 58 |
+
'usage_stats': event_logger.get_usage_stats(),
|
| 59 |
+
'event_counts': event_logger.get_event_counts()
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# Get recent events
|
| 63 |
+
recent_events = event_logger.get_events(limit=10)
|
| 64 |
+
|
| 65 |
+
# Get first 10 users for dashboard display
|
| 66 |
+
recent_users = all_users[:10]
|
| 67 |
+
users_data = []
|
| 68 |
+
for user in recent_users:
|
| 69 |
+
total_tokens = sum(count.total for count in user.token_counts.values())
|
| 70 |
+
total_requests = sum(usage.prompt_count for usage in user.ip_usage)
|
| 71 |
+
|
| 72 |
+
user_data = {
|
| 73 |
+
'user': user,
|
| 74 |
+
'stats': {
|
| 75 |
+
'total_tokens': total_tokens,
|
| 76 |
+
'total_requests': total_requests,
|
| 77 |
+
'ip_count': len(user.ip),
|
| 78 |
+
'days_since_created': (datetime.now() - user.created_at).days
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
users_data.append(user_data)
|
| 82 |
+
|
| 83 |
+
dashboard_config = {
|
| 84 |
+
'title': f"{os.getenv('BRAND_EMOJI', 'π±')} {os.getenv('DASHBOARD_TITLE', 'NyanProxy Admin')}",
|
| 85 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 86 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return render_template('admin/dashboard.html',
|
| 90 |
+
config=dashboard_config,
|
| 91 |
+
stats=stats,
|
| 92 |
+
recent_events=recent_events,
|
| 93 |
+
users_data=users_data,
|
| 94 |
+
auth_config=auth_config,
|
| 95 |
+
firebase_connected=hasattr(user_store, 'firebase_db') and user_store.firebase_db is not None)
|
| 96 |
+
|
| 97 |
+
@admin_web_bp.route('/users')
|
| 98 |
+
@require_admin_session
|
| 99 |
+
def list_users():
|
| 100 |
+
"""List all users with pagination and filtering"""
|
| 101 |
+
page = int(request.args.get('page', 1))
|
| 102 |
+
limit = int(request.args.get('limit', 25))
|
| 103 |
+
user_type = request.args.get('type')
|
| 104 |
+
search = request.args.get('search', '').lower()
|
| 105 |
+
|
| 106 |
+
all_users = user_store.get_all_users()
|
| 107 |
+
|
| 108 |
+
# Apply filters
|
| 109 |
+
filtered_users = []
|
| 110 |
+
for user in all_users:
|
| 111 |
+
if user_type and user.type.value != user_type:
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
if search:
|
| 115 |
+
if (search in user.token.lower() or
|
| 116 |
+
(user.nickname and search in user.nickname.lower()) or
|
| 117 |
+
(user.disabled_reason and search in user.disabled_reason.lower())):
|
| 118 |
+
filtered_users.append(user)
|
| 119 |
+
else:
|
| 120 |
+
filtered_users.append(user)
|
| 121 |
+
|
| 122 |
+
# Pagination
|
| 123 |
+
start = (page - 1) * limit
|
| 124 |
+
end = start + limit
|
| 125 |
+
paginated_users = filtered_users[start:end]
|
| 126 |
+
|
| 127 |
+
# Add usage statistics to user objects
|
| 128 |
+
users_data = []
|
| 129 |
+
for user in paginated_users:
|
| 130 |
+
# Add usage statistics
|
| 131 |
+
total_tokens = sum(count.total for count in user.token_counts.values())
|
| 132 |
+
total_requests = sum(usage.prompt_count for usage in user.ip_usage)
|
| 133 |
+
|
| 134 |
+
# Create a user data object that preserves datetime objects
|
| 135 |
+
user_data = {
|
| 136 |
+
'user': user, # Keep the original user object with datetime fields
|
| 137 |
+
'stats': {
|
| 138 |
+
'total_tokens': total_tokens,
|
| 139 |
+
'total_requests': total_requests,
|
| 140 |
+
'ip_count': len(user.ip),
|
| 141 |
+
'days_since_created': (datetime.now() - user.created_at).days
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
users_data.append(user_data)
|
| 146 |
+
|
| 147 |
+
pagination = {
|
| 148 |
+
'page': page,
|
| 149 |
+
'limit': limit,
|
| 150 |
+
'total': len(filtered_users),
|
| 151 |
+
'has_next': end < len(filtered_users)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
dashboard_config = {
|
| 155 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 156 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
return render_template('admin/list_users.html',
|
| 160 |
+
config=dashboard_config,
|
| 161 |
+
users=users_data,
|
| 162 |
+
pagination=pagination)
|
| 163 |
+
|
| 164 |
+
@admin_web_bp.route('/users/create', methods=['GET', 'POST'])
|
| 165 |
+
@require_admin_session
|
| 166 |
+
def create_user():
|
| 167 |
+
"""Create a new user"""
|
| 168 |
+
if request.method == 'POST':
|
| 169 |
+
user_type = UserType(request.form.get('type', 'normal'))
|
| 170 |
+
nickname = request.form.get('nickname') or None
|
| 171 |
+
|
| 172 |
+
# Parse token limits
|
| 173 |
+
token_limits = {}
|
| 174 |
+
if request.form.get('openai_limit'):
|
| 175 |
+
token_limits['openai'] = int(request.form.get('openai_limit'))
|
| 176 |
+
if request.form.get('anthropic_limit'):
|
| 177 |
+
token_limits['anthropic'] = int(request.form.get('anthropic_limit'))
|
| 178 |
+
|
| 179 |
+
# Parse temporary user options
|
| 180 |
+
prompt_limits = None
|
| 181 |
+
max_ips = None
|
| 182 |
+
if user_type == UserType.TEMPORARY:
|
| 183 |
+
if request.form.get('prompt_limits'):
|
| 184 |
+
prompt_limits = int(request.form.get('prompt_limits'))
|
| 185 |
+
if request.form.get('max_ips'):
|
| 186 |
+
max_ips = int(request.form.get('max_ips'))
|
| 187 |
+
|
| 188 |
+
try:
|
| 189 |
+
token = user_store.create_user(
|
| 190 |
+
user_type=user_type,
|
| 191 |
+
token_limits=token_limits,
|
| 192 |
+
nickname=nickname,
|
| 193 |
+
prompt_limits=prompt_limits,
|
| 194 |
+
max_ips=max_ips
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Log admin action (both old and new loggers)
|
| 198 |
+
event_logger.log_user_action(
|
| 199 |
+
token=token,
|
| 200 |
+
action='user_created',
|
| 201 |
+
details={
|
| 202 |
+
'created_by': 'admin',
|
| 203 |
+
'type': user_type.value,
|
| 204 |
+
'nickname': nickname,
|
| 205 |
+
'prompt_limits': prompt_limits,
|
| 206 |
+
'max_ips': max_ips
|
| 207 |
+
}
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Also log with structured logger
|
| 211 |
+
structured_logger.log_user_action(
|
| 212 |
+
user_token=token,
|
| 213 |
+
action='user_created',
|
| 214 |
+
details={
|
| 215 |
+
'created_by': 'admin',
|
| 216 |
+
'type': user_type.value,
|
| 217 |
+
'nickname': nickname,
|
| 218 |
+
'token_limits': token_limits,
|
| 219 |
+
'prompt_limits': prompt_limits,
|
| 220 |
+
'max_ips': max_ips
|
| 221 |
+
},
|
| 222 |
+
admin_user='admin'
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
flash(f'User created successfully! Token: {token}', 'success')
|
| 226 |
+
return redirect(url_for('admin.create_user') + '?created=true')
|
| 227 |
+
|
| 228 |
+
except Exception as e:
|
| 229 |
+
flash(f'Error creating user: {str(e)}', 'error')
|
| 230 |
+
|
| 231 |
+
# Get recent users for display
|
| 232 |
+
all_users = user_store.get_all_users()
|
| 233 |
+
recent_users = sorted(all_users, key=lambda u: u.created_at, reverse=True)[:5]
|
| 234 |
+
|
| 235 |
+
dashboard_config = {
|
| 236 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 237 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
return render_template('admin/create_user.html',
|
| 241 |
+
config=dashboard_config,
|
| 242 |
+
recent_users=recent_users,
|
| 243 |
+
new_user_created=request.args.get('created') == 'true')
|
| 244 |
+
|
| 245 |
+
@admin_web_bp.route('/users/<token>')
|
| 246 |
+
@require_admin_session
|
| 247 |
+
def view_user(token: str):
|
| 248 |
+
"""View detailed user information"""
|
| 249 |
+
user = user_store.get_user(token)
|
| 250 |
+
|
| 251 |
+
if not user:
|
| 252 |
+
flash('User not found', 'error')
|
| 253 |
+
return redirect(url_for('admin.list_users'))
|
| 254 |
+
|
| 255 |
+
# Add detailed statistics
|
| 256 |
+
usage_stats = event_logger.get_usage_stats(token)
|
| 257 |
+
|
| 258 |
+
# Add recent events
|
| 259 |
+
recent_events = event_logger.get_events(token=token, limit=20)
|
| 260 |
+
|
| 261 |
+
# Create user data object that preserves datetime objects
|
| 262 |
+
user_data = {
|
| 263 |
+
'user': user,
|
| 264 |
+
'usage_stats': usage_stats,
|
| 265 |
+
'recent_events': recent_events
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
dashboard_config = {
|
| 269 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 270 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
return render_template('admin/view_user.html',
|
| 274 |
+
config=dashboard_config,
|
| 275 |
+
user_data=user_data)
|
| 276 |
+
|
| 277 |
+
@admin_web_bp.route('/users/<token>/edit', methods=['GET', 'POST'])
|
| 278 |
+
@require_admin_session
|
| 279 |
+
def edit_user(token: str):
|
| 280 |
+
"""Edit user properties"""
|
| 281 |
+
user = user_store.get_user(token)
|
| 282 |
+
|
| 283 |
+
if not user:
|
| 284 |
+
flash('User not found', 'error')
|
| 285 |
+
return redirect(url_for('admin.list_users'))
|
| 286 |
+
|
| 287 |
+
if request.method == 'POST':
|
| 288 |
+
# Update nickname
|
| 289 |
+
if 'nickname' in request.form:
|
| 290 |
+
user.nickname = request.form['nickname']
|
| 291 |
+
|
| 292 |
+
# Update token limits
|
| 293 |
+
token_limits = {}
|
| 294 |
+
if request.form.get('openai_limit'):
|
| 295 |
+
token_limits['openai'] = int(request.form.get('openai_limit'))
|
| 296 |
+
if request.form.get('anthropic_limit'):
|
| 297 |
+
token_limits['anthropic'] = int(request.form.get('anthropic_limit'))
|
| 298 |
+
|
| 299 |
+
if token_limits:
|
| 300 |
+
user.token_limits.update(token_limits)
|
| 301 |
+
|
| 302 |
+
# Update user type
|
| 303 |
+
if 'type' in request.form:
|
| 304 |
+
user.type = UserType(request.form['type'])
|
| 305 |
+
|
| 306 |
+
# Add to flush queue
|
| 307 |
+
user_store.flush_queue.add(token)
|
| 308 |
+
|
| 309 |
+
# Log admin action
|
| 310 |
+
event_logger.log_user_action(
|
| 311 |
+
token=token,
|
| 312 |
+
action='user_updated',
|
| 313 |
+
details={
|
| 314 |
+
'updated_by': 'admin',
|
| 315 |
+
'changes': dict(request.form)
|
| 316 |
+
}
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
flash('User updated successfully', 'success')
|
| 320 |
+
return redirect(url_for('admin.view_user', token=token))
|
| 321 |
+
|
| 322 |
+
dashboard_config = {
|
| 323 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 324 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
return render_template('admin/edit_user.html',
|
| 328 |
+
config=dashboard_config,
|
| 329 |
+
user=user)
|
| 330 |
+
|
| 331 |
+
@admin_web_bp.route('/key-manager')
|
| 332 |
+
@require_admin_session
|
| 333 |
+
def key_manager():
|
| 334 |
+
"""API key management interface"""
|
| 335 |
+
dashboard_config = {
|
| 336 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 337 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
return render_template('admin/key_manager.html', config=dashboard_config)
|
| 341 |
+
|
| 342 |
+
@admin_web_bp.route('/anti-abuse')
|
| 343 |
+
@require_admin_session
|
| 344 |
+
def anti_abuse():
|
| 345 |
+
"""Anti-abuse settings interface"""
|
| 346 |
+
dashboard_config = {
|
| 347 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 348 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
return render_template('admin/anti_abuse.html', config=dashboard_config)
|
| 352 |
+
|
| 353 |
+
@admin_web_bp.route('/stats')
|
| 354 |
+
@require_admin_session
|
| 355 |
+
def stats():
|
| 356 |
+
"""Statistics and analytics interface"""
|
| 357 |
+
dashboard_config = {
|
| 358 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 359 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
return render_template('admin/stats.html', config=dashboard_config)
|
| 363 |
+
|
| 364 |
+
@admin_web_bp.route('/bulk-operations')
|
| 365 |
+
@require_admin_session
|
| 366 |
+
def bulk_operations():
|
| 367 |
+
"""Bulk operations interface"""
|
| 368 |
+
dashboard_config = {
|
| 369 |
+
'brand_name': os.getenv('BRAND_NAME', 'NyanProxy'),
|
| 370 |
+
'brand_emoji': os.getenv('BRAND_EMOJI', 'π±'),
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
return render_template('admin/bulk_operations.html', config=dashboard_config)
|
| 374 |
+
|
| 375 |
+
# Add AJAX routes for frontend user management operations
|
| 376 |
+
@admin_web_bp.route('/users/<token>', methods=['DELETE'])
|
| 377 |
+
@require_admin_session
|
| 378 |
+
def delete_user_web(token: str):
|
| 379 |
+
"""Delete a user via AJAX (automatically disables first if needed)"""
|
| 380 |
+
try:
|
| 381 |
+
user = user_store.get_user(token)
|
| 382 |
+
if not user:
|
| 383 |
+
return jsonify({'error': 'User not found'}), 404
|
| 384 |
+
|
| 385 |
+
# If user is not disabled, disable them first
|
| 386 |
+
if not user.is_disabled():
|
| 387 |
+
user_store.disable_user(token, "Disabled before deletion by admin")
|
| 388 |
+
|
| 389 |
+
# Log disable action
|
| 390 |
+
event_logger.log_user_action(
|
| 391 |
+
token=token,
|
| 392 |
+
action='user_disabled',
|
| 393 |
+
details={
|
| 394 |
+
'disabled_by': 'admin',
|
| 395 |
+
'reason': 'Disabled before deletion by admin'
|
| 396 |
+
}
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# Now delete the user
|
| 400 |
+
success = user_store.delete_user(token)
|
| 401 |
+
|
| 402 |
+
if not success:
|
| 403 |
+
return jsonify({'error': 'Failed to delete user'}), 500
|
| 404 |
+
|
| 405 |
+
# Log admin action
|
| 406 |
+
event_logger.log_user_action(
|
| 407 |
+
token=token,
|
| 408 |
+
action='user_deleted',
|
| 409 |
+
details={
|
| 410 |
+
'deleted_by': 'admin'
|
| 411 |
+
}
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
return jsonify({
|
| 415 |
+
'success': True,
|
| 416 |
+
'message': 'User deleted successfully'
|
| 417 |
+
})
|
| 418 |
+
|
| 419 |
+
except Exception as e:
|
| 420 |
+
return jsonify({
|
| 421 |
+
'success': False,
|
| 422 |
+
'error': f'Failed to delete user: {str(e)}'
|
| 423 |
+
}), 500
|
| 424 |
+
|
| 425 |
+
@admin_web_bp.route('/users/<token>/disable', methods=['POST'])
|
| 426 |
+
@require_admin_session
|
| 427 |
+
def disable_user_web(token: str):
|
| 428 |
+
"""Disable a user via AJAX"""
|
| 429 |
+
try:
|
| 430 |
+
# Handle both JSON and form data
|
| 431 |
+
if request.is_json:
|
| 432 |
+
data = request.get_json() or {}
|
| 433 |
+
reason = data.get('reason', 'Disabled by admin')
|
| 434 |
+
else:
|
| 435 |
+
reason = request.form.get('reason', 'Disabled by admin')
|
| 436 |
+
|
| 437 |
+
print(f"DEBUG: Attempting to disable user {token} with reason: {reason}")
|
| 438 |
+
|
| 439 |
+
# Check if user exists first
|
| 440 |
+
user = user_store.get_user(token)
|
| 441 |
+
if not user:
|
| 442 |
+
print(f"DEBUG: User {token} not found")
|
| 443 |
+
return jsonify({'error': 'User not found'}), 404
|
| 444 |
+
|
| 445 |
+
print(f"DEBUG: User found, current disabled status: {user.is_disabled()}")
|
| 446 |
+
|
| 447 |
+
success = user_store.disable_user(token, reason)
|
| 448 |
+
|
| 449 |
+
print(f"DEBUG: Disable operation result: {success}")
|
| 450 |
+
|
| 451 |
+
if not success:
|
| 452 |
+
return jsonify({'error': 'Failed to disable user'}), 500
|
| 453 |
+
|
| 454 |
+
# Log admin action
|
| 455 |
+
event_logger.log_user_action(
|
| 456 |
+
token=token,
|
| 457 |
+
action='user_disabled',
|
| 458 |
+
details={
|
| 459 |
+
'disabled_by': 'admin',
|
| 460 |
+
'reason': reason
|
| 461 |
+
}
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
return jsonify({
|
| 465 |
+
'success': True,
|
| 466 |
+
'message': 'User disabled successfully'
|
| 467 |
+
})
|
| 468 |
+
|
| 469 |
+
except Exception as e:
|
| 470 |
+
print(f"DEBUG: Exception occurred: {str(e)}")
|
| 471 |
+
return jsonify({
|
| 472 |
+
'success': False,
|
| 473 |
+
'error': f'Failed to disable user: {str(e)}'
|
| 474 |
+
}), 500
|
| 475 |
+
|
| 476 |
+
@admin_web_bp.route('/users/<token>/enable', methods=['POST'])
|
| 477 |
+
@require_admin_session
|
| 478 |
+
def enable_user_web(token: str):
|
| 479 |
+
"""Enable a user via AJAX"""
|
| 480 |
+
try:
|
| 481 |
+
success = user_store.reactivate_user(token)
|
| 482 |
+
|
| 483 |
+
if not success:
|
| 484 |
+
return jsonify({'error': 'User not found'}), 404
|
| 485 |
+
|
| 486 |
+
# Log admin action
|
| 487 |
+
event_logger.log_user_action(
|
| 488 |
+
token=token,
|
| 489 |
+
action='user_enabled',
|
| 490 |
+
details={
|
| 491 |
+
'enabled_by': 'admin'
|
| 492 |
+
}
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
return jsonify({
|
| 496 |
+
'success': True,
|
| 497 |
+
'message': 'User enabled successfully'
|
| 498 |
+
})
|
| 499 |
+
|
| 500 |
+
except Exception as e:
|
| 501 |
+
return jsonify({
|
| 502 |
+
'success': False,
|
| 503 |
+
'error': f'Failed to enable user: {str(e)}'
|
| 504 |
+
}), 500
|
| 505 |
+
|
| 506 |
+
# Include the existing API routes for AJAX calls
|
| 507 |
+
from .admin import admin_bp as admin_api_bp
|
src/routes/model_families_admin.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
π± Model Families Admin Interface
|
| 3 |
+
================================
|
| 4 |
+
|
| 5 |
+
Flask routes for managing model families through the admin interface.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from flask import Blueprint, request, jsonify, render_template, redirect, url_for, flash
|
| 9 |
+
from typing import Dict, Any, List
|
| 10 |
+
import json
|
| 11 |
+
|
| 12 |
+
from ..middleware.auth import require_admin_session
|
| 13 |
+
from ..services.model_families import model_manager, AIProvider
|
| 14 |
+
from ..services.structured_event_logger import structured_logger
|
| 15 |
+
|
| 16 |
+
model_families_bp = Blueprint('model_families', __name__, url_prefix='/admin/model-families')
|
| 17 |
+
|
| 18 |
+
@model_families_bp.route('/')
|
| 19 |
+
@require_admin_session
|
| 20 |
+
def model_families_dashboard():
|
| 21 |
+
"""Model families management dashboard"""
|
| 22 |
+
|
| 23 |
+
# Get all providers and their whitelisted models
|
| 24 |
+
providers_data = {}
|
| 25 |
+
total_models = 0
|
| 26 |
+
total_whitelisted = 0
|
| 27 |
+
|
| 28 |
+
for provider in AIProvider:
|
| 29 |
+
all_models = model_manager.get_all_models(provider)
|
| 30 |
+
whitelisted_models = model_manager.get_whitelisted_models(provider)
|
| 31 |
+
|
| 32 |
+
providers_data[provider.value] = {
|
| 33 |
+
'name': provider.value.title(),
|
| 34 |
+
'all_models': [
|
| 35 |
+
{
|
| 36 |
+
'id': model.model_id,
|
| 37 |
+
'display_name': model.display_name,
|
| 38 |
+
'description': model.description,
|
| 39 |
+
'is_whitelisted': model in whitelisted_models,
|
| 40 |
+
'input_cost': model.input_cost_per_1k,
|
| 41 |
+
'output_cost': model.output_cost_per_1k,
|
| 42 |
+
'context_length': model.context_length,
|
| 43 |
+
'is_premium': model.is_premium,
|
| 44 |
+
'cat_emoji': model.get_cat_emoji(),
|
| 45 |
+
'cat_personality': model.cat_personality
|
| 46 |
+
}
|
| 47 |
+
for model in all_models
|
| 48 |
+
],
|
| 49 |
+
'whitelisted_count': len(whitelisted_models),
|
| 50 |
+
'total_count': len(all_models)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
total_models += len(all_models)
|
| 54 |
+
total_whitelisted += len(whitelisted_models)
|
| 55 |
+
|
| 56 |
+
# Get usage statistics
|
| 57 |
+
global_usage = model_manager.get_usage_stats()
|
| 58 |
+
cost_analysis = model_manager.get_cost_analysis()
|
| 59 |
+
|
| 60 |
+
dashboard_config = {
|
| 61 |
+
'brand_name': 'NyanProxy',
|
| 62 |
+
'brand_emoji': 'π±',
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return render_template('admin/model_families.html',
|
| 66 |
+
config=dashboard_config,
|
| 67 |
+
providers=providers_data,
|
| 68 |
+
total_models=total_models,
|
| 69 |
+
total_whitelisted=total_whitelisted,
|
| 70 |
+
usage_stats=global_usage,
|
| 71 |
+
cost_analysis=cost_analysis)
|
| 72 |
+
|
| 73 |
+
@model_families_bp.route('/provider/<provider_name>')
|
| 74 |
+
@require_admin_session
|
| 75 |
+
def provider_details(provider_name: str):
|
| 76 |
+
"""Detailed view for a specific provider"""
|
| 77 |
+
try:
|
| 78 |
+
provider = AIProvider(provider_name.lower())
|
| 79 |
+
except ValueError:
|
| 80 |
+
flash(f'Unknown provider: {provider_name}', 'error')
|
| 81 |
+
return redirect(url_for('model_families.model_families_dashboard'))
|
| 82 |
+
|
| 83 |
+
all_models = model_manager.get_all_models(provider)
|
| 84 |
+
whitelisted_models = model_manager.get_whitelisted_models(provider)
|
| 85 |
+
|
| 86 |
+
models_data = []
|
| 87 |
+
for model in all_models:
|
| 88 |
+
usage_stats = model_manager.get_usage_stats(model_id=model.model_id)
|
| 89 |
+
|
| 90 |
+
models_data.append({
|
| 91 |
+
'info': model,
|
| 92 |
+
'is_whitelisted': model in whitelisted_models,
|
| 93 |
+
'usage_stats': usage_stats,
|
| 94 |
+
'cost_per_request': usage_stats.get('total_cost', 0) / max(usage_stats.get('total_requests', 1), 1)
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
dashboard_config = {
|
| 98 |
+
'brand_name': 'NyanProxy',
|
| 99 |
+
'brand_emoji': 'π±',
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return render_template('admin/provider_models.html',
|
| 103 |
+
config=dashboard_config,
|
| 104 |
+
provider=provider,
|
| 105 |
+
provider_name=provider_name.title(),
|
| 106 |
+
models=models_data)
|
| 107 |
+
|
| 108 |
+
@model_families_bp.route('/api/whitelist', methods=['POST'])
|
| 109 |
+
@require_admin_session
|
| 110 |
+
def update_whitelist():
|
| 111 |
+
"""Update model whitelist for a provider"""
|
| 112 |
+
data = request.get_json()
|
| 113 |
+
|
| 114 |
+
if not data or 'provider' not in data or 'models' not in data:
|
| 115 |
+
return jsonify({'error': 'Invalid request data'}), 400
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
provider = AIProvider(data['provider'].lower())
|
| 119 |
+
model_ids = data['models']
|
| 120 |
+
|
| 121 |
+
success = model_manager.set_provider_whitelist(provider, model_ids)
|
| 122 |
+
|
| 123 |
+
if success:
|
| 124 |
+
# Log admin action
|
| 125 |
+
structured_logger.log_user_action(
|
| 126 |
+
user_token='admin',
|
| 127 |
+
action='model_whitelist_updated',
|
| 128 |
+
details={
|
| 129 |
+
'provider': provider.value,
|
| 130 |
+
'models': model_ids,
|
| 131 |
+
'count': len(model_ids)
|
| 132 |
+
},
|
| 133 |
+
admin_user='admin'
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return jsonify({
|
| 137 |
+
'success': True,
|
| 138 |
+
'message': f'Updated whitelist for {provider.value}',
|
| 139 |
+
'whitelisted_count': len(model_ids)
|
| 140 |
+
})
|
| 141 |
+
else:
|
| 142 |
+
return jsonify({'error': 'Some models were invalid'}), 400
|
| 143 |
+
|
| 144 |
+
except ValueError as e:
|
| 145 |
+
return jsonify({'error': f'Invalid provider: {data["provider"]}'}), 400
|
| 146 |
+
except Exception as e:
|
| 147 |
+
return jsonify({'error': str(e)}), 500
|
| 148 |
+
|
| 149 |
+
@model_families_bp.route('/api/model/<model_id>/toggle', methods=['POST'])
|
| 150 |
+
@require_admin_session
|
| 151 |
+
def toggle_model_whitelist(model_id: str):
|
| 152 |
+
"""Toggle a single model's whitelist status"""
|
| 153 |
+
|
| 154 |
+
# Find which provider this model belongs to
|
| 155 |
+
model_info = model_manager.get_model_info(model_id)
|
| 156 |
+
if not model_info:
|
| 157 |
+
return jsonify({'error': 'Model not found'}), 404
|
| 158 |
+
|
| 159 |
+
provider = model_info.provider
|
| 160 |
+
is_whitelisted = model_manager.is_model_whitelisted(provider, model_id)
|
| 161 |
+
|
| 162 |
+
if is_whitelisted:
|
| 163 |
+
success = model_manager.remove_model_from_whitelist(provider, model_id)
|
| 164 |
+
action = 'removed from'
|
| 165 |
+
else:
|
| 166 |
+
success = model_manager.add_model_to_whitelist(provider, model_id)
|
| 167 |
+
action = 'added to'
|
| 168 |
+
|
| 169 |
+
if success:
|
| 170 |
+
# Log admin action
|
| 171 |
+
structured_logger.log_user_action(
|
| 172 |
+
user_token='admin',
|
| 173 |
+
action='model_whitelist_toggled',
|
| 174 |
+
details={
|
| 175 |
+
'model_id': model_id,
|
| 176 |
+
'provider': provider.value,
|
| 177 |
+
'action': action,
|
| 178 |
+
'display_name': model_info.display_name
|
| 179 |
+
},
|
| 180 |
+
admin_user='admin'
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
return jsonify({
|
| 184 |
+
'success': True,
|
| 185 |
+
'message': f'Model {action} whitelist',
|
| 186 |
+
'is_whitelisted': not is_whitelisted
|
| 187 |
+
})
|
| 188 |
+
else:
|
| 189 |
+
return jsonify({'error': 'Failed to update whitelist'}), 500
|
| 190 |
+
|
| 191 |
+
@model_families_bp.route('/api/usage-stats')
|
| 192 |
+
@require_admin_session
|
| 193 |
+
def get_usage_stats():
|
| 194 |
+
"""Get usage statistics for all models"""
|
| 195 |
+
user_token = request.args.get('user_token')
|
| 196 |
+
model_id = request.args.get('model_id')
|
| 197 |
+
|
| 198 |
+
stats = model_manager.get_usage_stats(user_token=user_token, model_id=model_id)
|
| 199 |
+
cost_analysis = model_manager.get_cost_analysis(user_token=user_token)
|
| 200 |
+
|
| 201 |
+
return jsonify({
|
| 202 |
+
'usage_stats': stats,
|
| 203 |
+
'cost_analysis': cost_analysis
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
@model_families_bp.route('/api/cost-analysis')
|
| 207 |
+
@require_admin_session
|
| 208 |
+
def get_cost_analysis():
|
| 209 |
+
"""Get detailed cost analysis"""
|
| 210 |
+
user_token = request.args.get('user_token')
|
| 211 |
+
|
| 212 |
+
analysis = model_manager.get_cost_analysis(user_token=user_token)
|
| 213 |
+
|
| 214 |
+
return jsonify(analysis)
|
| 215 |
+
|
| 216 |
+
@model_families_bp.route('/usage')
|
| 217 |
+
@require_admin_session
|
| 218 |
+
def usage_analytics():
|
| 219 |
+
"""Usage analytics page"""
|
| 220 |
+
|
| 221 |
+
# Get top models by usage
|
| 222 |
+
global_stats = model_manager.get_usage_stats()
|
| 223 |
+
cost_analysis = model_manager.get_cost_analysis()
|
| 224 |
+
|
| 225 |
+
# Sort models by total cost
|
| 226 |
+
top_models_by_cost = sorted(
|
| 227 |
+
cost_analysis.get('by_model', {}).items(),
|
| 228 |
+
key=lambda x: x[1]['cost'],
|
| 229 |
+
reverse=True
|
| 230 |
+
)[:10]
|
| 231 |
+
|
| 232 |
+
# Sort models by requests
|
| 233 |
+
top_models_by_requests = sorted(
|
| 234 |
+
global_stats.items(),
|
| 235 |
+
key=lambda x: x[1].get('total_requests', 0) if isinstance(x[1], dict) else 0,
|
| 236 |
+
reverse=True
|
| 237 |
+
)[:10]
|
| 238 |
+
|
| 239 |
+
dashboard_config = {
|
| 240 |
+
'brand_name': 'NyanProxy',
|
| 241 |
+
'brand_emoji': 'π±',
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
return render_template('admin/model_usage.html',
|
| 245 |
+
config=dashboard_config,
|
| 246 |
+
cost_analysis=cost_analysis,
|
| 247 |
+
top_models_by_cost=top_models_by_cost,
|
| 248 |
+
top_models_by_requests=top_models_by_requests,
|
| 249 |
+
global_stats=global_stats)
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services module
|
src/services/event_logger.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import sqlite3
|
| 3 |
+
import threading
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional, Dict, Any, List
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class Event:
|
| 10 |
+
token: str
|
| 11 |
+
event_type: str
|
| 12 |
+
payload: Dict[str, Any]
|
| 13 |
+
timestamp: datetime = None
|
| 14 |
+
|
| 15 |
+
def __post_init__(self):
|
| 16 |
+
if self.timestamp is None:
|
| 17 |
+
self.timestamp = datetime.now()
|
| 18 |
+
|
| 19 |
+
class EventLogger:
|
| 20 |
+
def __init__(self, db_path: str = "events.db"):
|
| 21 |
+
self.db_path = db_path
|
| 22 |
+
self.lock = threading.Lock()
|
| 23 |
+
self._initialize_db()
|
| 24 |
+
|
| 25 |
+
def _initialize_db(self):
|
| 26 |
+
"""Initialize SQLite database for event logging"""
|
| 27 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 28 |
+
conn.execute('''
|
| 29 |
+
CREATE TABLE IF NOT EXISTS events (
|
| 30 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 31 |
+
token TEXT NOT NULL,
|
| 32 |
+
event_type TEXT NOT NULL,
|
| 33 |
+
payload TEXT NOT NULL,
|
| 34 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 35 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 36 |
+
)
|
| 37 |
+
''')
|
| 38 |
+
|
| 39 |
+
# Create indexes for better performance
|
| 40 |
+
conn.execute('CREATE INDEX IF NOT EXISTS idx_token ON events(token)')
|
| 41 |
+
conn.execute('CREATE INDEX IF NOT EXISTS idx_event_type ON events(event_type)')
|
| 42 |
+
conn.execute('CREATE INDEX IF NOT EXISTS idx_timestamp ON events(timestamp)')
|
| 43 |
+
|
| 44 |
+
def log_event(self, token: str, event_type: str, payload: Dict[str, Any]):
|
| 45 |
+
"""Log an event to the database"""
|
| 46 |
+
with self.lock:
|
| 47 |
+
try:
|
| 48 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 49 |
+
conn.execute('''
|
| 50 |
+
INSERT INTO events (token, event_type, payload, timestamp)
|
| 51 |
+
VALUES (?, ?, ?, ?)
|
| 52 |
+
''', (token, event_type, json.dumps(payload), datetime.now().isoformat()))
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
print(f"Failed to log event: {e}")
|
| 56 |
+
|
| 57 |
+
def log_chat_completion(self, token: str, model_family: str,
|
| 58 |
+
input_tokens: int, output_tokens: int,
|
| 59 |
+
cost_usd: float = 0.0, ip_hash: str = None):
|
| 60 |
+
"""Log a chat completion event"""
|
| 61 |
+
payload = {
|
| 62 |
+
'model_family': model_family,
|
| 63 |
+
'input_tokens': input_tokens,
|
| 64 |
+
'output_tokens': output_tokens,
|
| 65 |
+
'total_tokens': input_tokens + output_tokens,
|
| 66 |
+
'cost_usd': cost_usd,
|
| 67 |
+
'ip_hash': ip_hash
|
| 68 |
+
}
|
| 69 |
+
self.log_event(token, 'chat_completion', payload)
|
| 70 |
+
|
| 71 |
+
def log_new_ip(self, token: str, ip_hash: str):
|
| 72 |
+
"""Log a new IP address for a user"""
|
| 73 |
+
payload = {
|
| 74 |
+
'ip_hash': ip_hash,
|
| 75 |
+
'action': 'new_ip_detected'
|
| 76 |
+
}
|
| 77 |
+
self.log_event(token, 'new_ip', payload)
|
| 78 |
+
|
| 79 |
+
def log_user_action(self, token: str, action: str, details: Dict[str, Any] = None):
|
| 80 |
+
"""Log a user action"""
|
| 81 |
+
payload = {
|
| 82 |
+
'action': action,
|
| 83 |
+
'details': details or {}
|
| 84 |
+
}
|
| 85 |
+
self.log_event(token, 'user_action', payload)
|
| 86 |
+
|
| 87 |
+
def get_events(self, token: str = None, event_type: str = None,
|
| 88 |
+
limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
|
| 89 |
+
"""Get events from database with optional filtering"""
|
| 90 |
+
with self.lock:
|
| 91 |
+
try:
|
| 92 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 93 |
+
conn.row_factory = sqlite3.Row
|
| 94 |
+
|
| 95 |
+
query = "SELECT * FROM events WHERE 1=1"
|
| 96 |
+
params = []
|
| 97 |
+
|
| 98 |
+
if token:
|
| 99 |
+
query += " AND token = ?"
|
| 100 |
+
params.append(token)
|
| 101 |
+
|
| 102 |
+
if event_type:
|
| 103 |
+
query += " AND event_type = ?"
|
| 104 |
+
params.append(event_type)
|
| 105 |
+
|
| 106 |
+
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
| 107 |
+
params.extend([limit, offset])
|
| 108 |
+
|
| 109 |
+
cursor = conn.execute(query, params)
|
| 110 |
+
events = []
|
| 111 |
+
|
| 112 |
+
for row in cursor.fetchall():
|
| 113 |
+
event_dict = dict(row)
|
| 114 |
+
event_dict['payload'] = json.loads(event_dict['payload'])
|
| 115 |
+
events.append(event_dict)
|
| 116 |
+
|
| 117 |
+
return events
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print(f"Failed to get events: {e}")
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
def get_event_counts(self, token: str = None) -> Dict[str, int]:
|
| 124 |
+
"""Get event counts by type"""
|
| 125 |
+
with self.lock:
|
| 126 |
+
try:
|
| 127 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 128 |
+
query = "SELECT event_type, COUNT(*) as count FROM events"
|
| 129 |
+
params = []
|
| 130 |
+
|
| 131 |
+
if token:
|
| 132 |
+
query += " WHERE token = ?"
|
| 133 |
+
params.append(token)
|
| 134 |
+
|
| 135 |
+
query += " GROUP BY event_type"
|
| 136 |
+
|
| 137 |
+
cursor = conn.execute(query, params)
|
| 138 |
+
return {row[0]: row[1] for row in cursor.fetchall()}
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"Failed to get event counts: {e}")
|
| 142 |
+
return {}
|
| 143 |
+
|
| 144 |
+
def get_usage_stats(self, token: str = None) -> Dict[str, Any]:
|
| 145 |
+
"""Get usage statistics from events"""
|
| 146 |
+
with self.lock:
|
| 147 |
+
try:
|
| 148 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 149 |
+
# Get token usage by model family
|
| 150 |
+
query = '''
|
| 151 |
+
SELECT
|
| 152 |
+
json_extract(payload, '$.model_family') as model_family,
|
| 153 |
+
SUM(json_extract(payload, '$.input_tokens')) as input_tokens,
|
| 154 |
+
SUM(json_extract(payload, '$.output_tokens')) as output_tokens,
|
| 155 |
+
SUM(json_extract(payload, '$.total_tokens')) as total_tokens,
|
| 156 |
+
SUM(json_extract(payload, '$.cost_usd')) as cost_usd,
|
| 157 |
+
COUNT(*) as requests
|
| 158 |
+
FROM events
|
| 159 |
+
WHERE event_type = 'chat_completion'
|
| 160 |
+
'''
|
| 161 |
+
|
| 162 |
+
params = []
|
| 163 |
+
if token:
|
| 164 |
+
query += " AND token = ?"
|
| 165 |
+
params.append(token)
|
| 166 |
+
|
| 167 |
+
query += " GROUP BY model_family"
|
| 168 |
+
|
| 169 |
+
cursor = conn.execute(query, params)
|
| 170 |
+
usage_by_model = {}
|
| 171 |
+
|
| 172 |
+
for row in cursor.fetchall():
|
| 173 |
+
model_family = row[0]
|
| 174 |
+
usage_by_model[model_family] = {
|
| 175 |
+
'input_tokens': row[1] or 0,
|
| 176 |
+
'output_tokens': row[2] or 0,
|
| 177 |
+
'total_tokens': row[3] or 0,
|
| 178 |
+
'cost_usd': row[4] or 0.0,
|
| 179 |
+
'requests': row[5] or 0
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
'usage_by_model': usage_by_model,
|
| 184 |
+
'total_requests': sum(stats['requests'] for stats in usage_by_model.values()),
|
| 185 |
+
'total_tokens': sum(stats['total_tokens'] for stats in usage_by_model.values()),
|
| 186 |
+
'total_cost': sum(stats['cost_usd'] for stats in usage_by_model.values())
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
print(f"Failed to get usage stats: {e}")
|
| 191 |
+
return {}
|
| 192 |
+
|
| 193 |
+
# Global event logger instance
|
| 194 |
+
event_logger = EventLogger()
|
src/services/model_families.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
π± Model Families System for NyanProxy
|
| 3 |
+
=====================================
|
| 4 |
+
|
| 5 |
+
This module provides comprehensive model management with:
|
| 6 |
+
- Whitelisting specific models for each AI service
|
| 7 |
+
- Individual model usage tracking
|
| 8 |
+
- Cost calculation per model
|
| 9 |
+
- Cat-themed model categorization
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import threading
|
| 14 |
+
from typing import Dict, List, Optional, Set, Any
|
| 15 |
+
from dataclasses import dataclass, asdict
|
| 16 |
+
from enum import Enum
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
import firebase_admin
|
| 22 |
+
from firebase_admin import credentials, db
|
| 23 |
+
FIREBASE_AVAILABLE = True
|
| 24 |
+
except ImportError:
|
| 25 |
+
FIREBASE_AVAILABLE = False
|
| 26 |
+
|
| 27 |
+
from ..config.auth import auth_config
|
| 28 |
+
|
| 29 |
+
class AIProvider(Enum):
|
| 30 |
+
OPENAI = "openai"
|
| 31 |
+
ANTHROPIC = "anthropic"
|
| 32 |
+
GOOGLE = "google"
|
| 33 |
+
MISTRAL = "mistral"
|
| 34 |
+
GROQ = "groq"
|
| 35 |
+
COHERE = "cohere"
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class ModelInfo:
|
| 39 |
+
"""Information about a specific AI model"""
|
| 40 |
+
model_id: str
|
| 41 |
+
provider: AIProvider
|
| 42 |
+
display_name: str
|
| 43 |
+
description: str
|
| 44 |
+
input_cost_per_1k: float # Cost per 1000 input tokens
|
| 45 |
+
output_cost_per_1k: float # Cost per 1000 output tokens
|
| 46 |
+
context_length: int
|
| 47 |
+
supports_streaming: bool = True
|
| 48 |
+
supports_function_calling: bool = False
|
| 49 |
+
is_premium: bool = False
|
| 50 |
+
cat_personality: str = "curious" # curious, playful, sleepy, grumpy
|
| 51 |
+
|
| 52 |
+
def calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
|
| 53 |
+
"""Calculate cost for token usage"""
|
| 54 |
+
input_cost = (input_tokens / 1000) * self.input_cost_per_1k
|
| 55 |
+
output_cost = (output_tokens / 1000) * self.output_cost_per_1k
|
| 56 |
+
return input_cost + output_cost
|
| 57 |
+
|
| 58 |
+
def get_cat_emoji(self) -> str:
|
| 59 |
+
"""Get cat emoji based on personality"""
|
| 60 |
+
personalities = {
|
| 61 |
+
"curious": "π",
|
| 62 |
+
"playful": "πΈ",
|
| 63 |
+
"sleepy": "π΄",
|
| 64 |
+
"grumpy": "πΎ",
|
| 65 |
+
"smart": "π€",
|
| 66 |
+
"fast": "π¨"
|
| 67 |
+
}
|
| 68 |
+
return personalities.get(self.cat_personality, "πΊ")
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class ModelUsageStats:
|
| 72 |
+
"""Usage statistics for a specific model"""
|
| 73 |
+
model_id: str
|
| 74 |
+
total_requests: int = 0
|
| 75 |
+
total_input_tokens: int = 0
|
| 76 |
+
total_output_tokens: int = 0
|
| 77 |
+
total_cost: float = 0.0
|
| 78 |
+
first_used: Optional[datetime] = None
|
| 79 |
+
last_used: Optional[datetime] = None
|
| 80 |
+
error_count: int = 0
|
| 81 |
+
success_rate: float = 100.0
|
| 82 |
+
|
| 83 |
+
def add_usage(self, input_tokens: int, output_tokens: int, cost: float, success: bool = True):
|
| 84 |
+
"""Add usage data"""
|
| 85 |
+
self.total_requests += 1
|
| 86 |
+
self.total_input_tokens += input_tokens
|
| 87 |
+
self.total_output_tokens += output_tokens
|
| 88 |
+
self.total_cost += cost
|
| 89 |
+
|
| 90 |
+
now = datetime.now()
|
| 91 |
+
if self.first_used is None:
|
| 92 |
+
self.first_used = now
|
| 93 |
+
self.last_used = now
|
| 94 |
+
|
| 95 |
+
if not success:
|
| 96 |
+
self.error_count += 1
|
| 97 |
+
|
| 98 |
+
# Update success rate
|
| 99 |
+
self.success_rate = ((self.total_requests - self.error_count) / self.total_requests) * 100
|
| 100 |
+
|
| 101 |
+
def to_dict(self) -> dict:
|
| 102 |
+
return {
|
| 103 |
+
'model_id': self.model_id,
|
| 104 |
+
'total_requests': self.total_requests,
|
| 105 |
+
'total_input_tokens': self.total_input_tokens,
|
| 106 |
+
'total_output_tokens': self.total_output_tokens,
|
| 107 |
+
'total_cost': self.total_cost,
|
| 108 |
+
'first_used': self.first_used.isoformat() if self.first_used else None,
|
| 109 |
+
'last_used': self.last_used.isoformat() if self.last_used else None,
|
| 110 |
+
'error_count': self.error_count,
|
| 111 |
+
'success_rate': self.success_rate
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
@classmethod
|
| 115 |
+
def from_dict(cls, data: dict):
|
| 116 |
+
return cls(
|
| 117 |
+
model_id=data['model_id'],
|
| 118 |
+
total_requests=data.get('total_requests', 0),
|
| 119 |
+
total_input_tokens=data.get('total_input_tokens', 0),
|
| 120 |
+
total_output_tokens=data.get('total_output_tokens', 0),
|
| 121 |
+
total_cost=data.get('total_cost', 0.0),
|
| 122 |
+
first_used=datetime.fromisoformat(data['first_used']) if data.get('first_used') else None,
|
| 123 |
+
last_used=datetime.fromisoformat(data['last_used']) if data.get('last_used') else None,
|
| 124 |
+
error_count=data.get('error_count', 0),
|
| 125 |
+
success_rate=data.get('success_rate', 100.0)
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
class ModelFamilyManager:
|
| 129 |
+
"""
|
| 130 |
+
πΎ Model Family Management System
|
| 131 |
+
|
| 132 |
+
Manages whitelisted models for each AI service and tracks individual usage.
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
def __init__(self):
|
| 136 |
+
self.lock = threading.Lock()
|
| 137 |
+
self.models: Dict[str, ModelInfo] = {}
|
| 138 |
+
self.whitelisted_models: Dict[AIProvider, Set[str]] = {}
|
| 139 |
+
self.global_usage_stats: Dict[str, ModelUsageStats] = {}
|
| 140 |
+
self.user_usage_stats: Dict[str, Dict[str, ModelUsageStats]] = {} # user_token -> model_id -> stats
|
| 141 |
+
self.firebase_db = None
|
| 142 |
+
|
| 143 |
+
# Initialize with default models
|
| 144 |
+
self._initialize_default_models()
|
| 145 |
+
|
| 146 |
+
# Initialize Firebase if available
|
| 147 |
+
if FIREBASE_AVAILABLE and auth_config.firebase_url:
|
| 148 |
+
self._initialize_firebase()
|
| 149 |
+
|
| 150 |
+
# Load configuration
|
| 151 |
+
self._load_configuration()
|
| 152 |
+
|
| 153 |
+
def _initialize_default_models(self):
|
| 154 |
+
"""Initialize with default model configurations"""
|
| 155 |
+
default_models = [
|
| 156 |
+
# OpenAI Models
|
| 157 |
+
ModelInfo(
|
| 158 |
+
model_id="gpt-4o",
|
| 159 |
+
provider=AIProvider.OPENAI,
|
| 160 |
+
display_name="GPT-4o",
|
| 161 |
+
description="Most advanced GPT-4 model with vision capabilities",
|
| 162 |
+
input_cost_per_1k=5.0,
|
| 163 |
+
output_cost_per_1k=15.0,
|
| 164 |
+
context_length=128000,
|
| 165 |
+
supports_function_calling=True,
|
| 166 |
+
is_premium=True,
|
| 167 |
+
cat_personality="smart"
|
| 168 |
+
),
|
| 169 |
+
ModelInfo(
|
| 170 |
+
model_id="gpt-4o-mini",
|
| 171 |
+
provider=AIProvider.OPENAI,
|
| 172 |
+
display_name="GPT-4o Mini",
|
| 173 |
+
description="Smaller, faster GPT-4o model",
|
| 174 |
+
input_cost_per_1k=0.15,
|
| 175 |
+
output_cost_per_1k=0.6,
|
| 176 |
+
context_length=128000,
|
| 177 |
+
supports_function_calling=True,
|
| 178 |
+
cat_personality="fast"
|
| 179 |
+
),
|
| 180 |
+
ModelInfo(
|
| 181 |
+
model_id="gpt-3.5-turbo",
|
| 182 |
+
provider=AIProvider.OPENAI,
|
| 183 |
+
display_name="GPT-3.5 Turbo",
|
| 184 |
+
description="Fast and efficient model for most tasks",
|
| 185 |
+
input_cost_per_1k=0.5,
|
| 186 |
+
output_cost_per_1k=1.5,
|
| 187 |
+
context_length=16385,
|
| 188 |
+
supports_function_calling=True,
|
| 189 |
+
cat_personality="playful"
|
| 190 |
+
),
|
| 191 |
+
|
| 192 |
+
# Anthropic Models
|
| 193 |
+
ModelInfo(
|
| 194 |
+
model_id="claude-3-5-sonnet-20241022",
|
| 195 |
+
provider=AIProvider.ANTHROPIC,
|
| 196 |
+
display_name="Claude 3.5 Sonnet",
|
| 197 |
+
description="Most intelligent Claude model",
|
| 198 |
+
input_cost_per_1k=3.0,
|
| 199 |
+
output_cost_per_1k=15.0,
|
| 200 |
+
context_length=200000,
|
| 201 |
+
is_premium=True,
|
| 202 |
+
cat_personality="smart"
|
| 203 |
+
),
|
| 204 |
+
ModelInfo(
|
| 205 |
+
model_id="claude-3-haiku-20240307",
|
| 206 |
+
provider=AIProvider.ANTHROPIC,
|
| 207 |
+
display_name="Claude 3 Haiku",
|
| 208 |
+
description="Fastest Claude model for simple tasks",
|
| 209 |
+
input_cost_per_1k=0.25,
|
| 210 |
+
output_cost_per_1k=1.25,
|
| 211 |
+
context_length=200000,
|
| 212 |
+
cat_personality="fast"
|
| 213 |
+
),
|
| 214 |
+
|
| 215 |
+
# Google Models
|
| 216 |
+
ModelInfo(
|
| 217 |
+
model_id="gemini-1.5-pro",
|
| 218 |
+
provider=AIProvider.GOOGLE,
|
| 219 |
+
display_name="Gemini 1.5 Pro",
|
| 220 |
+
description="Google's most capable model",
|
| 221 |
+
input_cost_per_1k=3.5,
|
| 222 |
+
output_cost_per_1k=10.5,
|
| 223 |
+
context_length=2000000,
|
| 224 |
+
is_premium=True,
|
| 225 |
+
cat_personality="curious"
|
| 226 |
+
),
|
| 227 |
+
ModelInfo(
|
| 228 |
+
model_id="gemini-1.5-flash",
|
| 229 |
+
provider=AIProvider.GOOGLE,
|
| 230 |
+
display_name="Gemini 1.5 Flash",
|
| 231 |
+
description="Fast and efficient Gemini model",
|
| 232 |
+
input_cost_per_1k=0.075,
|
| 233 |
+
output_cost_per_1k=0.3,
|
| 234 |
+
context_length=1000000,
|
| 235 |
+
cat_personality="fast"
|
| 236 |
+
),
|
| 237 |
+
]
|
| 238 |
+
|
| 239 |
+
for model in default_models:
|
| 240 |
+
self.models[model.model_id] = model
|
| 241 |
+
|
| 242 |
+
# Initialize default whitelists (all models enabled by default)
|
| 243 |
+
for provider in AIProvider:
|
| 244 |
+
self.whitelisted_models[provider] = set()
|
| 245 |
+
provider_models = [m.model_id for m in default_models if m.provider == provider]
|
| 246 |
+
self.whitelisted_models[provider].update(provider_models)
|
| 247 |
+
|
| 248 |
+
def _initialize_firebase(self):
|
| 249 |
+
"""Initialize Firebase connection for persistent storage"""
|
| 250 |
+
try:
|
| 251 |
+
# Firebase is already initialized in user_store, just get reference
|
| 252 |
+
self.firebase_db = db.reference()
|
| 253 |
+
print("Model families connected to Firebase")
|
| 254 |
+
|
| 255 |
+
# Load existing configuration
|
| 256 |
+
self._load_from_firebase()
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"Failed to connect model families to Firebase: {e}")
|
| 260 |
+
self.firebase_db = None
|
| 261 |
+
|
| 262 |
+
def _load_configuration(self):
|
| 263 |
+
"""Load configuration from local file if Firebase not available"""
|
| 264 |
+
if self.firebase_db:
|
| 265 |
+
return # Firebase handles persistence
|
| 266 |
+
|
| 267 |
+
config_file = "model_families_config.json"
|
| 268 |
+
if os.path.exists(config_file):
|
| 269 |
+
try:
|
| 270 |
+
with open(config_file, 'r') as f:
|
| 271 |
+
config = json.load(f)
|
| 272 |
+
|
| 273 |
+
# Load whitelisted models
|
| 274 |
+
for provider_str, model_list in config.get('whitelisted_models', {}).items():
|
| 275 |
+
try:
|
| 276 |
+
provider = AIProvider(provider_str)
|
| 277 |
+
self.whitelisted_models[provider] = set(model_list)
|
| 278 |
+
except ValueError:
|
| 279 |
+
print(f"Unknown provider in config: {provider_str}")
|
| 280 |
+
|
| 281 |
+
print(f"Loaded model families configuration from {config_file}")
|
| 282 |
+
|
| 283 |
+
except Exception as e:
|
| 284 |
+
print(f"Failed to load model families config: {e}")
|
| 285 |
+
|
| 286 |
+
def _load_from_firebase(self):
|
| 287 |
+
"""Load configuration from Firebase"""
|
| 288 |
+
try:
|
| 289 |
+
if not self.firebase_db:
|
| 290 |
+
return
|
| 291 |
+
|
| 292 |
+
config_ref = self.firebase_db.child('model_families_config')
|
| 293 |
+
config = config_ref.get()
|
| 294 |
+
|
| 295 |
+
if config:
|
| 296 |
+
# Load whitelisted models
|
| 297 |
+
for provider_str, model_list in config.get('whitelisted_models', {}).items():
|
| 298 |
+
try:
|
| 299 |
+
provider = AIProvider(provider_str)
|
| 300 |
+
self.whitelisted_models[provider] = set(model_list)
|
| 301 |
+
except ValueError:
|
| 302 |
+
print(f"Unknown provider in Firebase config: {provider_str}")
|
| 303 |
+
|
| 304 |
+
print("Loaded model families configuration from Firebase")
|
| 305 |
+
|
| 306 |
+
except Exception as e:
|
| 307 |
+
print(f"Failed to load model families from Firebase: {e}")
|
| 308 |
+
|
| 309 |
+
def _save_configuration(self):
|
| 310 |
+
"""Save configuration to persistent storage"""
|
| 311 |
+
config = {
|
| 312 |
+
'whitelisted_models': {
|
| 313 |
+
provider.value: list(models)
|
| 314 |
+
for provider, models in self.whitelisted_models.items()
|
| 315 |
+
},
|
| 316 |
+
'last_updated': datetime.now().isoformat()
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
if self.firebase_db:
|
| 320 |
+
try:
|
| 321 |
+
config_ref = self.firebase_db.child('model_families_config')
|
| 322 |
+
config_ref.set(config)
|
| 323 |
+
except Exception as e:
|
| 324 |
+
print(f"Failed to save model families to Firebase: {e}")
|
| 325 |
+
else:
|
| 326 |
+
try:
|
| 327 |
+
with open("model_families_config.json", 'w') as f:
|
| 328 |
+
json.dump(config, f, indent=2)
|
| 329 |
+
except Exception as e:
|
| 330 |
+
print(f"Failed to save model families to file: {e}")
|
| 331 |
+
|
| 332 |
+
def is_model_whitelisted(self, provider: AIProvider, model_id: str) -> bool:
|
| 333 |
+
"""Check if a model is whitelisted for use"""
|
| 334 |
+
with self.lock:
|
| 335 |
+
return model_id in self.whitelisted_models.get(provider, set())
|
| 336 |
+
|
| 337 |
+
def get_whitelisted_models(self, provider: AIProvider) -> List[ModelInfo]:
|
| 338 |
+
"""Get all whitelisted models for a provider"""
|
| 339 |
+
with self.lock:
|
| 340 |
+
whitelisted_ids = self.whitelisted_models.get(provider, set())
|
| 341 |
+
return [
|
| 342 |
+
self.models[model_id]
|
| 343 |
+
for model_id in whitelisted_ids
|
| 344 |
+
if model_id in self.models
|
| 345 |
+
]
|
| 346 |
+
|
| 347 |
+
def get_all_models(self, provider: Optional[AIProvider] = None) -> List[ModelInfo]:
|
| 348 |
+
"""Get all available models, optionally filtered by provider"""
|
| 349 |
+
with self.lock:
|
| 350 |
+
if provider:
|
| 351 |
+
return [model for model in self.models.values() if model.provider == provider]
|
| 352 |
+
return list(self.models.values())
|
| 353 |
+
|
| 354 |
+
def add_model_to_whitelist(self, provider: AIProvider, model_id: str) -> bool:
|
| 355 |
+
"""Add a model to the whitelist"""
|
| 356 |
+
with self.lock:
|
| 357 |
+
if model_id not in self.models:
|
| 358 |
+
return False
|
| 359 |
+
|
| 360 |
+
if provider not in self.whitelisted_models:
|
| 361 |
+
self.whitelisted_models[provider] = set()
|
| 362 |
+
|
| 363 |
+
self.whitelisted_models[provider].add(model_id)
|
| 364 |
+
self._save_configuration()
|
| 365 |
+
return True
|
| 366 |
+
|
| 367 |
+
def remove_model_from_whitelist(self, provider: AIProvider, model_id: str) -> bool:
|
| 368 |
+
"""Remove a model from the whitelist"""
|
| 369 |
+
with self.lock:
|
| 370 |
+
if provider not in self.whitelisted_models:
|
| 371 |
+
return False
|
| 372 |
+
|
| 373 |
+
if model_id in self.whitelisted_models[provider]:
|
| 374 |
+
self.whitelisted_models[provider].remove(model_id)
|
| 375 |
+
self._save_configuration()
|
| 376 |
+
return True
|
| 377 |
+
|
| 378 |
+
return False
|
| 379 |
+
|
| 380 |
+
def set_provider_whitelist(self, provider: AIProvider, model_ids: List[str]) -> bool:
|
| 381 |
+
"""Set the complete whitelist for a provider"""
|
| 382 |
+
with self.lock:
|
| 383 |
+
# Validate all model IDs exist
|
| 384 |
+
valid_ids = [mid for mid in model_ids if mid in self.models]
|
| 385 |
+
|
| 386 |
+
self.whitelisted_models[provider] = set(valid_ids)
|
| 387 |
+
self._save_configuration()
|
| 388 |
+
return len(valid_ids) == len(model_ids)
|
| 389 |
+
|
| 390 |
+
def track_model_usage(self, user_token: str, model_id: str, input_tokens: int,
|
| 391 |
+
output_tokens: int, success: bool = True) -> Optional[float]:
|
| 392 |
+
"""Track usage for a specific model and return cost"""
|
| 393 |
+
with self.lock:
|
| 394 |
+
if model_id not in self.models:
|
| 395 |
+
return None
|
| 396 |
+
|
| 397 |
+
model_info = self.models[model_id]
|
| 398 |
+
cost = model_info.calculate_cost(input_tokens, output_tokens)
|
| 399 |
+
|
| 400 |
+
# Update global stats
|
| 401 |
+
if model_id not in self.global_usage_stats:
|
| 402 |
+
self.global_usage_stats[model_id] = ModelUsageStats(model_id=model_id)
|
| 403 |
+
|
| 404 |
+
self.global_usage_stats[model_id].add_usage(input_tokens, output_tokens, cost, success)
|
| 405 |
+
|
| 406 |
+
# Update user-specific stats
|
| 407 |
+
if user_token not in self.user_usage_stats:
|
| 408 |
+
self.user_usage_stats[user_token] = {}
|
| 409 |
+
|
| 410 |
+
if model_id not in self.user_usage_stats[user_token]:
|
| 411 |
+
self.user_usage_stats[user_token][model_id] = ModelUsageStats(model_id=model_id)
|
| 412 |
+
|
| 413 |
+
self.user_usage_stats[user_token][model_id].add_usage(input_tokens, output_tokens, cost, success)
|
| 414 |
+
|
| 415 |
+
return cost
|
| 416 |
+
|
| 417 |
+
def get_model_info(self, model_id: str) -> Optional[ModelInfo]:
|
| 418 |
+
"""Get information about a specific model"""
|
| 419 |
+
with self.lock:
|
| 420 |
+
return self.models.get(model_id)
|
| 421 |
+
|
| 422 |
+
def get_usage_stats(self, user_token: Optional[str] = None, model_id: Optional[str] = None) -> Dict[str, Any]:
|
| 423 |
+
"""Get usage statistics"""
|
| 424 |
+
with self.lock:
|
| 425 |
+
if user_token and model_id:
|
| 426 |
+
# Specific user and model
|
| 427 |
+
if user_token in self.user_usage_stats and model_id in self.user_usage_stats[user_token]:
|
| 428 |
+
return self.user_usage_stats[user_token][model_id].to_dict()
|
| 429 |
+
return {}
|
| 430 |
+
|
| 431 |
+
elif user_token:
|
| 432 |
+
# All models for a specific user
|
| 433 |
+
if user_token in self.user_usage_stats:
|
| 434 |
+
return {
|
| 435 |
+
model_id: stats.to_dict()
|
| 436 |
+
for model_id, stats in self.user_usage_stats[user_token].items()
|
| 437 |
+
}
|
| 438 |
+
return {}
|
| 439 |
+
|
| 440 |
+
elif model_id:
|
| 441 |
+
# Specific model across all users
|
| 442 |
+
if model_id in self.global_usage_stats:
|
| 443 |
+
return self.global_usage_stats[model_id].to_dict()
|
| 444 |
+
return {}
|
| 445 |
+
|
| 446 |
+
else:
|
| 447 |
+
# Global stats for all models
|
| 448 |
+
return {
|
| 449 |
+
model_id: stats.to_dict()
|
| 450 |
+
for model_id, stats in self.global_usage_stats.items()
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
def get_cost_analysis(self, user_token: Optional[str] = None) -> Dict[str, Any]:
|
| 454 |
+
"""Get detailed cost analysis"""
|
| 455 |
+
with self.lock:
|
| 456 |
+
if user_token:
|
| 457 |
+
# User-specific analysis
|
| 458 |
+
if user_token not in self.user_usage_stats:
|
| 459 |
+
return {'total_cost': 0, 'by_model': {}, 'by_provider': {}}
|
| 460 |
+
|
| 461 |
+
user_stats = self.user_usage_stats[user_token]
|
| 462 |
+
else:
|
| 463 |
+
# Global analysis
|
| 464 |
+
user_stats = self.global_usage_stats
|
| 465 |
+
|
| 466 |
+
total_cost = 0.0
|
| 467 |
+
by_model = {}
|
| 468 |
+
by_provider = {}
|
| 469 |
+
|
| 470 |
+
for model_id, stats in user_stats.items():
|
| 471 |
+
model_info = self.models.get(model_id)
|
| 472 |
+
if not model_info:
|
| 473 |
+
continue
|
| 474 |
+
|
| 475 |
+
provider = model_info.provider.value
|
| 476 |
+
model_cost = stats.total_cost
|
| 477 |
+
|
| 478 |
+
total_cost += model_cost
|
| 479 |
+
by_model[model_id] = {
|
| 480 |
+
'cost': model_cost,
|
| 481 |
+
'requests': stats.total_requests,
|
| 482 |
+
'display_name': model_info.display_name,
|
| 483 |
+
'provider': provider
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
if provider not in by_provider:
|
| 487 |
+
by_provider[provider] = {'cost': 0, 'requests': 0, 'models': []}
|
| 488 |
+
|
| 489 |
+
by_provider[provider]['cost'] += model_cost
|
| 490 |
+
by_provider[provider]['requests'] += stats.total_requests
|
| 491 |
+
by_provider[provider]['models'].append(model_id)
|
| 492 |
+
|
| 493 |
+
return {
|
| 494 |
+
'total_cost': total_cost,
|
| 495 |
+
'by_model': by_model,
|
| 496 |
+
'by_provider': by_provider,
|
| 497 |
+
'currency': 'USD'
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
# Global instance
|
| 501 |
+
model_manager = ModelFamilyManager()
|
src/services/structured_event_logger.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
π± Structured Event Logging System for NyanProxy
|
| 3 |
+
===============================================
|
| 4 |
+
|
| 5 |
+
This module provides a comprehensive event logging system with proper schema,
|
| 6 |
+
indexing, and cat-themed event types for our purr-fect proxy service!
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import sqlite3
|
| 11 |
+
import threading
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
from typing import Optional, Dict, Any, List, Union, Literal
|
| 14 |
+
from dataclasses import dataclass, asdict
|
| 15 |
+
from enum import Enum
|
| 16 |
+
import uuid
|
| 17 |
+
import hashlib
|
| 18 |
+
import queue
|
| 19 |
+
from contextlib import contextmanager
|
| 20 |
+
|
| 21 |
+
# Cat-themed event types πΎ
|
| 22 |
+
class EventType(Enum):
|
| 23 |
+
# Request events
|
| 24 |
+
CHAT_COMPLETION = "chat_completion"
|
| 25 |
+
STREAMING_REQUEST = "streaming_request"
|
| 26 |
+
MODEL_LIST_REQUEST = "model_list_request"
|
| 27 |
+
|
| 28 |
+
# Authentication events
|
| 29 |
+
LOGIN_SUCCESS = "login_success"
|
| 30 |
+
LOGIN_FAILURE = "login_failure"
|
| 31 |
+
TOKEN_CREATED = "token_created"
|
| 32 |
+
TOKEN_ROTATED = "token_rotated"
|
| 33 |
+
|
| 34 |
+
# User management events
|
| 35 |
+
USER_CREATED = "user_created"
|
| 36 |
+
USER_UPDATED = "user_updated"
|
| 37 |
+
USER_DISABLED = "user_disabled"
|
| 38 |
+
USER_ENABLED = "user_enabled"
|
| 39 |
+
USER_DELETED = "user_deleted"
|
| 40 |
+
|
| 41 |
+
# IP and security events
|
| 42 |
+
NEW_IP_DETECTED = "new_ip_detected"
|
| 43 |
+
SUSPICIOUS_ACTIVITY = "suspicious_activity"
|
| 44 |
+
RATE_LIMIT_HIT = "rate_limit_hit"
|
| 45 |
+
IP_BANNED = "ip_banned"
|
| 46 |
+
|
| 47 |
+
# System events
|
| 48 |
+
SYSTEM_STARTUP = "system_startup"
|
| 49 |
+
SYSTEM_SHUTDOWN = "system_shutdown"
|
| 50 |
+
KEY_HEALTH_CHECK = "key_health_check"
|
| 51 |
+
|
| 52 |
+
# Cat-themed events π±
|
| 53 |
+
MEOW_SUCCESSFUL = "meow_successful" # Successful API call
|
| 54 |
+
HISS_ERROR = "hiss_error" # Error occurred
|
| 55 |
+
PURR_HAPPY = "purr_happy" # User satisfied
|
| 56 |
+
SCRATCH_ABUSE = "scratch_abuse" # Abuse detected
|
| 57 |
+
|
| 58 |
+
class EventSeverity(Enum):
|
| 59 |
+
DEBUG = "debug"
|
| 60 |
+
INFO = "info"
|
| 61 |
+
WARNING = "warning"
|
| 62 |
+
ERROR = "error"
|
| 63 |
+
CRITICAL = "critical"
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class BaseEvent:
|
| 67 |
+
"""Base event structure"""
|
| 68 |
+
event_id: str = ""
|
| 69 |
+
event_type: EventType = EventType.SYSTEM_STARTUP
|
| 70 |
+
timestamp: datetime = None
|
| 71 |
+
severity: EventSeverity = EventSeverity.INFO
|
| 72 |
+
user_token: Optional[str] = None
|
| 73 |
+
ip_hash: Optional[str] = None
|
| 74 |
+
user_agent: Optional[str] = None
|
| 75 |
+
|
| 76 |
+
def __post_init__(self):
|
| 77 |
+
if not self.event_id:
|
| 78 |
+
self.event_id = str(uuid.uuid4())
|
| 79 |
+
if not self.timestamp:
|
| 80 |
+
self.timestamp = datetime.now()
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class ChatCompletionEvent(BaseEvent):
|
| 84 |
+
"""Chat completion request event"""
|
| 85 |
+
model_family: str = ""
|
| 86 |
+
model_name: str = ""
|
| 87 |
+
input_tokens: int = 0
|
| 88 |
+
output_tokens: int = 0
|
| 89 |
+
total_tokens: int = 0
|
| 90 |
+
cost_usd: float = 0.0
|
| 91 |
+
response_time_ms: float = 0.0
|
| 92 |
+
success: bool = False
|
| 93 |
+
error_message: Optional[str] = None
|
| 94 |
+
|
| 95 |
+
def __post_init__(self):
|
| 96 |
+
super().__post_init__()
|
| 97 |
+
self.event_type = EventType.CHAT_COMPLETION
|
| 98 |
+
|
| 99 |
+
@dataclass
|
| 100 |
+
class UserManagementEvent(BaseEvent):
|
| 101 |
+
"""User management event"""
|
| 102 |
+
target_user_token: str = ""
|
| 103 |
+
action: str = ""
|
| 104 |
+
details: Dict[str, Any] = None
|
| 105 |
+
admin_user: Optional[str] = None
|
| 106 |
+
|
| 107 |
+
def __post_init__(self):
|
| 108 |
+
super().__post_init__()
|
| 109 |
+
if self.details is None:
|
| 110 |
+
self.details = {}
|
| 111 |
+
self.event_type = EventType.USER_UPDATED
|
| 112 |
+
|
| 113 |
+
@dataclass
|
| 114 |
+
class SecurityEvent(BaseEvent):
|
| 115 |
+
"""Security-related event"""
|
| 116 |
+
threat_type: str = ""
|
| 117 |
+
details: Dict[str, Any] = None
|
| 118 |
+
auto_resolved: bool = False
|
| 119 |
+
|
| 120 |
+
def __post_init__(self):
|
| 121 |
+
super().__post_init__()
|
| 122 |
+
if self.details is None:
|
| 123 |
+
self.details = {}
|
| 124 |
+
self.event_type = EventType.SUSPICIOUS_ACTIVITY
|
| 125 |
+
|
| 126 |
+
@dataclass
|
| 127 |
+
class SystemEvent(BaseEvent):
|
| 128 |
+
"""System-level event"""
|
| 129 |
+
component: str = ""
|
| 130 |
+
message: str = ""
|
| 131 |
+
details: Dict[str, Any] = None
|
| 132 |
+
|
| 133 |
+
def __post_init__(self):
|
| 134 |
+
super().__post_init__()
|
| 135 |
+
if self.details is None:
|
| 136 |
+
self.details = {}
|
| 137 |
+
self.event_type = EventType.SYSTEM_STARTUP
|
| 138 |
+
|
| 139 |
+
class DatabaseConnectionPool:
|
| 140 |
+
"""Thread-safe database connection pool"""
|
| 141 |
+
|
| 142 |
+
def __init__(self, db_path: str, max_connections: int = 10):
|
| 143 |
+
self.db_path = db_path
|
| 144 |
+
self.max_connections = max_connections
|
| 145 |
+
self.pool = queue.Queue(maxsize=max_connections)
|
| 146 |
+
self.lock = threading.Lock()
|
| 147 |
+
self._initialize_pool()
|
| 148 |
+
|
| 149 |
+
def _initialize_pool(self):
|
| 150 |
+
"""Initialize the connection pool"""
|
| 151 |
+
for _ in range(self.max_connections):
|
| 152 |
+
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
| 153 |
+
conn.execute("PRAGMA journal_mode=WAL") # Enable WAL mode for better concurrency
|
| 154 |
+
conn.execute("PRAGMA synchronous=NORMAL") # Faster writes
|
| 155 |
+
conn.execute("PRAGMA cache_size=10000") # Increase cache
|
| 156 |
+
conn.execute("PRAGMA temp_store=memory") # Use memory for temp tables
|
| 157 |
+
self.pool.put(conn)
|
| 158 |
+
|
| 159 |
+
@contextmanager
|
| 160 |
+
def get_connection(self):
|
| 161 |
+
"""Get a connection from the pool"""
|
| 162 |
+
conn = self.pool.get()
|
| 163 |
+
try:
|
| 164 |
+
yield conn
|
| 165 |
+
finally:
|
| 166 |
+
self.pool.put(conn)
|
| 167 |
+
|
| 168 |
+
def close_all(self):
|
| 169 |
+
"""Close all connections in the pool"""
|
| 170 |
+
with self.lock:
|
| 171 |
+
while not self.pool.empty():
|
| 172 |
+
conn = self.pool.get()
|
| 173 |
+
conn.close()
|
| 174 |
+
|
| 175 |
+
class StructuredEventLogger:
|
| 176 |
+
"""
|
| 177 |
+
πΎ Structured Event Logger with Cat-themed Features
|
| 178 |
+
|
| 179 |
+
This logger provides comprehensive event tracking with:
|
| 180 |
+
- Proper event schemas and types
|
| 181 |
+
- Fast querying with indexes
|
| 182 |
+
- Cat-themed event categorization
|
| 183 |
+
- Automatic data retention
|
| 184 |
+
- Performance metrics
|
| 185 |
+
- Database connection pooling
|
| 186 |
+
"""
|
| 187 |
+
|
| 188 |
+
def __init__(self, db_path: str = "nyan_events.db"):
|
| 189 |
+
self.db_path = db_path
|
| 190 |
+
self.lock = threading.Lock()
|
| 191 |
+
self.db_pool = DatabaseConnectionPool(db_path)
|
| 192 |
+
self._initialize_db()
|
| 193 |
+
|
| 194 |
+
# Performance tracking
|
| 195 |
+
self.events_logged_today = 0
|
| 196 |
+
self.last_cleanup = datetime.now()
|
| 197 |
+
self.performance_metrics = {
|
| 198 |
+
'total_events': 0,
|
| 199 |
+
'events_per_minute': 0,
|
| 200 |
+
'avg_log_time_ms': 0,
|
| 201 |
+
'storage_size_mb': 0
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
def _initialize_db(self):
|
| 205 |
+
"""Initialize the SQLite database with proper schema"""
|
| 206 |
+
with self.db_pool.get_connection() as conn:
|
| 207 |
+
# Main events table
|
| 208 |
+
conn.execute('''
|
| 209 |
+
CREATE TABLE IF NOT EXISTS events (
|
| 210 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 211 |
+
event_id TEXT UNIQUE NOT NULL,
|
| 212 |
+
event_type TEXT NOT NULL,
|
| 213 |
+
severity TEXT NOT NULL,
|
| 214 |
+
timestamp DATETIME NOT NULL,
|
| 215 |
+
user_token TEXT,
|
| 216 |
+
ip_hash TEXT,
|
| 217 |
+
user_agent TEXT,
|
| 218 |
+
event_data TEXT NOT NULL,
|
| 219 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 220 |
+
)
|
| 221 |
+
''')
|
| 222 |
+
|
| 223 |
+
# Chat completion events (optimized for common queries)
|
| 224 |
+
conn.execute('''
|
| 225 |
+
CREATE TABLE IF NOT EXISTS chat_completions (
|
| 226 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 227 |
+
event_id TEXT NOT NULL,
|
| 228 |
+
user_token TEXT,
|
| 229 |
+
model_family TEXT NOT NULL,
|
| 230 |
+
model_name TEXT NOT NULL,
|
| 231 |
+
input_tokens INTEGER NOT NULL,
|
| 232 |
+
output_tokens INTEGER NOT NULL,
|
| 233 |
+
total_tokens INTEGER NOT NULL,
|
| 234 |
+
cost_usd REAL NOT NULL,
|
| 235 |
+
response_time_ms REAL NOT NULL,
|
| 236 |
+
success BOOLEAN NOT NULL,
|
| 237 |
+
timestamp DATETIME NOT NULL,
|
| 238 |
+
FOREIGN KEY(event_id) REFERENCES events(event_id)
|
| 239 |
+
)
|
| 240 |
+
''')
|
| 241 |
+
|
| 242 |
+
# User activity tracking
|
| 243 |
+
conn.execute('''
|
| 244 |
+
CREATE TABLE IF NOT EXISTS user_activities (
|
| 245 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 246 |
+
event_id TEXT NOT NULL,
|
| 247 |
+
user_token TEXT NOT NULL,
|
| 248 |
+
action TEXT NOT NULL,
|
| 249 |
+
details TEXT,
|
| 250 |
+
timestamp DATETIME NOT NULL,
|
| 251 |
+
FOREIGN KEY(event_id) REFERENCES events(event_id)
|
| 252 |
+
)
|
| 253 |
+
''')
|
| 254 |
+
|
| 255 |
+
# Security events
|
| 256 |
+
conn.execute('''
|
| 257 |
+
CREATE TABLE IF NOT EXISTS security_events (
|
| 258 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 259 |
+
event_id TEXT NOT NULL,
|
| 260 |
+
threat_type TEXT NOT NULL,
|
| 261 |
+
ip_hash TEXT,
|
| 262 |
+
user_token TEXT,
|
| 263 |
+
details TEXT NOT NULL,
|
| 264 |
+
auto_resolved BOOLEAN DEFAULT FALSE,
|
| 265 |
+
timestamp DATETIME NOT NULL,
|
| 266 |
+
FOREIGN KEY(event_id) REFERENCES events(event_id)
|
| 267 |
+
)
|
| 268 |
+
''')
|
| 269 |
+
|
| 270 |
+
# Create indexes for performance
|
| 271 |
+
self._create_indexes(conn)
|
| 272 |
+
|
| 273 |
+
def _create_indexes(self, conn):
|
| 274 |
+
"""Create database indexes for optimal query performance"""
|
| 275 |
+
indexes = [
|
| 276 |
+
'CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp)',
|
| 277 |
+
'CREATE INDEX IF NOT EXISTS idx_events_user_token ON events(user_token)',
|
| 278 |
+
'CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type)',
|
| 279 |
+
'CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity)',
|
| 280 |
+
'CREATE INDEX IF NOT EXISTS idx_events_ip_hash ON events(ip_hash)',
|
| 281 |
+
|
| 282 |
+
'CREATE INDEX IF NOT EXISTS idx_chat_user_token ON chat_completions(user_token)',
|
| 283 |
+
'CREATE INDEX IF NOT EXISTS idx_chat_model_family ON chat_completions(model_family)',
|
| 284 |
+
'CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_completions(timestamp)',
|
| 285 |
+
'CREATE INDEX IF NOT EXISTS idx_chat_success ON chat_completions(success)',
|
| 286 |
+
|
| 287 |
+
'CREATE INDEX IF NOT EXISTS idx_user_activities_token ON user_activities(user_token)',
|
| 288 |
+
'CREATE INDEX IF NOT EXISTS idx_user_activities_action ON user_activities(action)',
|
| 289 |
+
'CREATE INDEX IF NOT EXISTS idx_user_activities_timestamp ON user_activities(timestamp)',
|
| 290 |
+
|
| 291 |
+
'CREATE INDEX IF NOT EXISTS idx_security_threat_type ON security_events(threat_type)',
|
| 292 |
+
'CREATE INDEX IF NOT EXISTS idx_security_ip_hash ON security_events(ip_hash)',
|
| 293 |
+
'CREATE INDEX IF NOT EXISTS idx_security_timestamp ON security_events(timestamp)',
|
| 294 |
+
]
|
| 295 |
+
|
| 296 |
+
for index_sql in indexes:
|
| 297 |
+
conn.execute(index_sql)
|
| 298 |
+
|
| 299 |
+
def log_chat_completion(self, user_token: str, model_family: str, model_name: str,
|
| 300 |
+
input_tokens: int, output_tokens: int, cost_usd: float,
|
| 301 |
+
response_time_ms: float, success: bool, ip_hash: str = None,
|
| 302 |
+
user_agent: str = None, error_message: str = None):
|
| 303 |
+
"""Log a chat completion event with full details"""
|
| 304 |
+
|
| 305 |
+
event = ChatCompletionEvent(
|
| 306 |
+
event_id=str(uuid.uuid4()),
|
| 307 |
+
event_type=EventType.CHAT_COMPLETION,
|
| 308 |
+
timestamp=datetime.now(),
|
| 309 |
+
severity=EventSeverity.INFO if success else EventSeverity.WARNING,
|
| 310 |
+
user_token=user_token,
|
| 311 |
+
ip_hash=ip_hash,
|
| 312 |
+
user_agent=user_agent,
|
| 313 |
+
model_family=model_family,
|
| 314 |
+
model_name=model_name,
|
| 315 |
+
input_tokens=input_tokens,
|
| 316 |
+
output_tokens=output_tokens,
|
| 317 |
+
total_tokens=input_tokens + output_tokens,
|
| 318 |
+
cost_usd=cost_usd,
|
| 319 |
+
response_time_ms=response_time_ms,
|
| 320 |
+
success=success,
|
| 321 |
+
error_message=error_message
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
self._log_event(event)
|
| 325 |
+
|
| 326 |
+
# Also log to specialized chat_completions table
|
| 327 |
+
with self.lock:
|
| 328 |
+
try:
|
| 329 |
+
with self.db_pool.get_connection() as conn:
|
| 330 |
+
conn.execute('''
|
| 331 |
+
INSERT INTO chat_completions (
|
| 332 |
+
event_id, user_token, model_family, model_name,
|
| 333 |
+
input_tokens, output_tokens, total_tokens, cost_usd,
|
| 334 |
+
response_time_ms, success, timestamp
|
| 335 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 336 |
+
''', (
|
| 337 |
+
event.event_id, user_token, model_family, model_name,
|
| 338 |
+
input_tokens, output_tokens, input_tokens + output_tokens,
|
| 339 |
+
cost_usd, response_time_ms, success, event.timestamp
|
| 340 |
+
))
|
| 341 |
+
except Exception as e:
|
| 342 |
+
print(f"ERROR: Failed to log chat completion: {e}")
|
| 343 |
+
|
| 344 |
+
def log_user_action(self, user_token: str, action: str, details: Dict[str, Any] = None,
|
| 345 |
+
admin_user: str = None, ip_hash: str = None):
|
| 346 |
+
"""Log user management action"""
|
| 347 |
+
|
| 348 |
+
event = UserManagementEvent(
|
| 349 |
+
event_id=str(uuid.uuid4()),
|
| 350 |
+
event_type=EventType.USER_UPDATED,
|
| 351 |
+
timestamp=datetime.now(),
|
| 352 |
+
severity=EventSeverity.INFO,
|
| 353 |
+
user_token=user_token,
|
| 354 |
+
ip_hash=ip_hash,
|
| 355 |
+
target_user_token=user_token,
|
| 356 |
+
action=action,
|
| 357 |
+
details=details or {},
|
| 358 |
+
admin_user=admin_user
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
self._log_event(event)
|
| 362 |
+
|
| 363 |
+
# Also log to user_activities table
|
| 364 |
+
with self.lock:
|
| 365 |
+
try:
|
| 366 |
+
with self.db_pool.get_connection() as conn:
|
| 367 |
+
conn.execute('''
|
| 368 |
+
INSERT INTO user_activities (
|
| 369 |
+
event_id, user_token, action, details, timestamp
|
| 370 |
+
) VALUES (?, ?, ?, ?, ?)
|
| 371 |
+
''', (
|
| 372 |
+
event.event_id, user_token, action,
|
| 373 |
+
json.dumps(details) if details else None,
|
| 374 |
+
event.timestamp
|
| 375 |
+
))
|
| 376 |
+
except Exception as e:
|
| 377 |
+
print(f"ERROR: Failed to log user action: {e}")
|
| 378 |
+
|
| 379 |
+
def log_security_event(self, threat_type: str, ip_hash: str = None, user_token: str = None,
|
| 380 |
+
details: Dict[str, Any] = None, auto_resolved: bool = False):
|
| 381 |
+
"""Log security-related event"""
|
| 382 |
+
|
| 383 |
+
event = SecurityEvent(
|
| 384 |
+
event_id=str(uuid.uuid4()),
|
| 385 |
+
event_type=EventType.SUSPICIOUS_ACTIVITY,
|
| 386 |
+
timestamp=datetime.now(),
|
| 387 |
+
severity=EventSeverity.WARNING if not auto_resolved else EventSeverity.INFO,
|
| 388 |
+
user_token=user_token,
|
| 389 |
+
ip_hash=ip_hash,
|
| 390 |
+
threat_type=threat_type,
|
| 391 |
+
details=details or {},
|
| 392 |
+
auto_resolved=auto_resolved
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
self._log_event(event)
|
| 396 |
+
|
| 397 |
+
# Also log to security_events table
|
| 398 |
+
with self.lock:
|
| 399 |
+
try:
|
| 400 |
+
with self.db_pool.get_connection() as conn:
|
| 401 |
+
conn.execute('''
|
| 402 |
+
INSERT INTO security_events (
|
| 403 |
+
event_id, threat_type, ip_hash, user_token,
|
| 404 |
+
details, auto_resolved, timestamp
|
| 405 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 406 |
+
''', (
|
| 407 |
+
event.event_id, threat_type, ip_hash, user_token,
|
| 408 |
+
json.dumps(details) if details else None,
|
| 409 |
+
auto_resolved, event.timestamp
|
| 410 |
+
))
|
| 411 |
+
except Exception as e:
|
| 412 |
+
print(f"ERROR: Failed to log security event: {e}")
|
| 413 |
+
|
| 414 |
+
def log_system_event(self, component: str, message: str, details: Dict[str, Any] = None,
|
| 415 |
+
severity: EventSeverity = EventSeverity.INFO):
|
| 416 |
+
"""Log system-level event"""
|
| 417 |
+
|
| 418 |
+
event = SystemEvent(
|
| 419 |
+
event_id=str(uuid.uuid4()),
|
| 420 |
+
event_type=EventType.SYSTEM_STARTUP,
|
| 421 |
+
timestamp=datetime.now(),
|
| 422 |
+
severity=severity,
|
| 423 |
+
component=component,
|
| 424 |
+
message=message,
|
| 425 |
+
details=details or {}
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
self._log_event(event)
|
| 429 |
+
|
| 430 |
+
def _log_event(self, event: BaseEvent):
|
| 431 |
+
"""Internal method to log an event to the database"""
|
| 432 |
+
with self.lock:
|
| 433 |
+
try:
|
| 434 |
+
with self.db_pool.get_connection() as conn:
|
| 435 |
+
conn.execute('''
|
| 436 |
+
INSERT INTO events (
|
| 437 |
+
event_id, event_type, severity, timestamp,
|
| 438 |
+
user_token, ip_hash, user_agent, event_data
|
| 439 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 440 |
+
''', (
|
| 441 |
+
event.event_id,
|
| 442 |
+
event.event_type.value,
|
| 443 |
+
event.severity.value,
|
| 444 |
+
event.timestamp,
|
| 445 |
+
event.user_token,
|
| 446 |
+
event.ip_hash,
|
| 447 |
+
event.user_agent,
|
| 448 |
+
json.dumps(asdict(event))
|
| 449 |
+
))
|
| 450 |
+
|
| 451 |
+
self.events_logged_today += 1
|
| 452 |
+
self.performance_metrics['total_events'] += 1
|
| 453 |
+
|
| 454 |
+
except Exception as e:
|
| 455 |
+
print(f"ERROR: Failed to log event: {e}")
|
| 456 |
+
|
| 457 |
+
def get_usage_stats(self, user_token: str = None) -> Dict[str, Any]:
|
| 458 |
+
"""Get comprehensive usage statistics"""
|
| 459 |
+
with self.lock:
|
| 460 |
+
try:
|
| 461 |
+
with self.db_pool.get_connection() as conn:
|
| 462 |
+
if user_token:
|
| 463 |
+
# User-specific stats
|
| 464 |
+
cursor = conn.execute('''
|
| 465 |
+
SELECT
|
| 466 |
+
model_family,
|
| 467 |
+
SUM(input_tokens) as input_tokens,
|
| 468 |
+
SUM(output_tokens) as output_tokens,
|
| 469 |
+
SUM(total_tokens) as total_tokens,
|
| 470 |
+
SUM(cost_usd) as cost_usd,
|
| 471 |
+
COUNT(*) as requests,
|
| 472 |
+
AVG(response_time_ms) as avg_response_time,
|
| 473 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_requests
|
| 474 |
+
FROM chat_completions
|
| 475 |
+
WHERE user_token = ?
|
| 476 |
+
GROUP BY model_family
|
| 477 |
+
''', (user_token,))
|
| 478 |
+
else:
|
| 479 |
+
# System-wide stats
|
| 480 |
+
cursor = conn.execute('''
|
| 481 |
+
SELECT
|
| 482 |
+
model_family,
|
| 483 |
+
SUM(input_tokens) as input_tokens,
|
| 484 |
+
SUM(output_tokens) as output_tokens,
|
| 485 |
+
SUM(total_tokens) as total_tokens,
|
| 486 |
+
SUM(cost_usd) as cost_usd,
|
| 487 |
+
COUNT(*) as requests,
|
| 488 |
+
AVG(response_time_ms) as avg_response_time,
|
| 489 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_requests
|
| 490 |
+
FROM chat_completions
|
| 491 |
+
GROUP BY model_family
|
| 492 |
+
''')
|
| 493 |
+
|
| 494 |
+
usage_by_model = {}
|
| 495 |
+
total_requests = 0
|
| 496 |
+
total_tokens = 0
|
| 497 |
+
total_cost = 0.0
|
| 498 |
+
|
| 499 |
+
for row in cursor.fetchall():
|
| 500 |
+
model_family = row[0]
|
| 501 |
+
usage_by_model[model_family] = {
|
| 502 |
+
'input_tokens': row[1] or 0,
|
| 503 |
+
'output_tokens': row[2] or 0,
|
| 504 |
+
'total_tokens': row[3] or 0,
|
| 505 |
+
'cost_usd': row[4] or 0.0,
|
| 506 |
+
'requests': row[5] or 0,
|
| 507 |
+
'avg_response_time': row[6] or 0.0,
|
| 508 |
+
'successful_requests': row[7] or 0,
|
| 509 |
+
'success_rate': (row[7] or 0) / (row[5] or 1) * 100
|
| 510 |
+
}
|
| 511 |
+
total_requests += row[5] or 0
|
| 512 |
+
total_tokens += row[3] or 0
|
| 513 |
+
total_cost += row[4] or 0.0
|
| 514 |
+
|
| 515 |
+
return {
|
| 516 |
+
'usage_by_model': usage_by_model,
|
| 517 |
+
'total_requests': total_requests,
|
| 518 |
+
'total_tokens': total_tokens,
|
| 519 |
+
'total_cost': total_cost,
|
| 520 |
+
'avg_cost_per_request': total_cost / total_requests if total_requests > 0 else 0
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
except Exception as e:
|
| 524 |
+
print(f"ERROR: Failed to get usage stats: {e}")
|
| 525 |
+
return {}
|
| 526 |
+
|
| 527 |
+
def get_events(self, event_type: str = None, user_token: str = None,
|
| 528 |
+
severity: str = None, limit: int = 100, offset: int = 0,
|
| 529 |
+
start_date: datetime = None, end_date: datetime = None) -> List[Dict[str, Any]]:
|
| 530 |
+
"""Get events with advanced filtering options"""
|
| 531 |
+
|
| 532 |
+
with self.lock:
|
| 533 |
+
try:
|
| 534 |
+
with self.db_pool.get_connection() as conn:
|
| 535 |
+
conn.row_factory = sqlite3.Row
|
| 536 |
+
|
| 537 |
+
query = "SELECT * FROM events WHERE 1=1"
|
| 538 |
+
params = []
|
| 539 |
+
|
| 540 |
+
if event_type:
|
| 541 |
+
query += " AND event_type = ?"
|
| 542 |
+
params.append(event_type)
|
| 543 |
+
|
| 544 |
+
if user_token:
|
| 545 |
+
query += " AND user_token = ?"
|
| 546 |
+
params.append(user_token)
|
| 547 |
+
|
| 548 |
+
if severity:
|
| 549 |
+
query += " AND severity = ?"
|
| 550 |
+
params.append(severity)
|
| 551 |
+
|
| 552 |
+
if start_date:
|
| 553 |
+
query += " AND timestamp >= ?"
|
| 554 |
+
params.append(start_date.isoformat())
|
| 555 |
+
|
| 556 |
+
if end_date:
|
| 557 |
+
query += " AND timestamp <= ?"
|
| 558 |
+
params.append(end_date.isoformat())
|
| 559 |
+
|
| 560 |
+
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
| 561 |
+
params.extend([limit, offset])
|
| 562 |
+
|
| 563 |
+
cursor = conn.execute(query, params)
|
| 564 |
+
events = []
|
| 565 |
+
|
| 566 |
+
for row in cursor.fetchall():
|
| 567 |
+
event_dict = dict(row)
|
| 568 |
+
try:
|
| 569 |
+
event_dict['payload'] = json.loads(event_dict['event_data'])
|
| 570 |
+
except:
|
| 571 |
+
event_dict['payload'] = {}
|
| 572 |
+
|
| 573 |
+
# Convert timestamp to datetime object
|
| 574 |
+
event_dict['timestamp'] = datetime.fromisoformat(event_dict['timestamp'])
|
| 575 |
+
|
| 576 |
+
events.append(event_dict)
|
| 577 |
+
|
| 578 |
+
return events
|
| 579 |
+
|
| 580 |
+
except Exception as e:
|
| 581 |
+
print(f"ERROR: Failed to get events: {e}")
|
| 582 |
+
return []
|
| 583 |
+
|
| 584 |
+
def get_event_counts(self, user_token: str = None) -> Dict[str, int]:
|
| 585 |
+
"""Get event counts by type"""
|
| 586 |
+
with self.lock:
|
| 587 |
+
try:
|
| 588 |
+
with self.db_pool.get_connection() as conn:
|
| 589 |
+
query = "SELECT event_type, COUNT(*) as count FROM events"
|
| 590 |
+
params = []
|
| 591 |
+
|
| 592 |
+
if user_token:
|
| 593 |
+
query += " WHERE user_token = ?"
|
| 594 |
+
params.append(user_token)
|
| 595 |
+
|
| 596 |
+
query += " GROUP BY event_type"
|
| 597 |
+
|
| 598 |
+
cursor = conn.execute(query, params)
|
| 599 |
+
return {row[0]: row[1] for row in cursor.fetchall()}
|
| 600 |
+
|
| 601 |
+
except Exception as e:
|
| 602 |
+
print(f"ERROR: Failed to get event counts: {e}")
|
| 603 |
+
return {}
|
| 604 |
+
|
| 605 |
+
def cleanup_old_events(self, days_to_keep: int = 30):
|
| 606 |
+
"""Clean up old events to manage database size"""
|
| 607 |
+
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
|
| 608 |
+
|
| 609 |
+
with self.lock:
|
| 610 |
+
try:
|
| 611 |
+
with self.db_pool.get_connection() as conn:
|
| 612 |
+
# Delete old events
|
| 613 |
+
cursor = conn.execute('''
|
| 614 |
+
DELETE FROM events WHERE timestamp < ?
|
| 615 |
+
''', (cutoff_date,))
|
| 616 |
+
|
| 617 |
+
deleted_count = cursor.rowcount
|
| 618 |
+
|
| 619 |
+
# Vacuum database to reclaim space
|
| 620 |
+
conn.execute('VACUUM')
|
| 621 |
+
|
| 622 |
+
print(f"Cleaned up {deleted_count} old events from database")
|
| 623 |
+
|
| 624 |
+
except Exception as e:
|
| 625 |
+
print(f"ERROR: Failed to cleanup old events: {e}")
|
| 626 |
+
|
| 627 |
+
# Global instance
|
| 628 |
+
structured_logger = StructuredEventLogger()
|