WasabiDrop Claude commited on
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
Files changed (50) hide show
  1. .claude/settings.local.json +27 -1
  2. .env +49 -11
  3. .env.example +25 -0
  4. .gitignore +7 -1
  5. AUTHENTICATION.md +334 -0
  6. README.md +5 -5
  7. {tokenizers β†’ ai/tokenizers}/__init__.py +0 -0
  8. {tokenizers β†’ ai/tokenizers}/anthropic_tokenizer.py +0 -0
  9. {tokenizers β†’ ai/tokenizers}/claude.ts +0 -0
  10. {tokenizers β†’ ai/tokenizers}/index.ts +0 -0
  11. {tokenizers β†’ ai/tokenizers}/mistral-tokenizer-js.ts +0 -0
  12. {tokenizers β†’ ai/tokenizers}/mistral.ts +0 -0
  13. {tokenizers β†’ ai/tokenizers}/openai.ts +0 -0
  14. {tokenizers β†’ ai/tokenizers}/openai_tokenizer.py +0 -0
  15. {tokenizers β†’ ai/tokenizers}/tokenizer.ts +0 -0
  16. {tokenizers β†’ ai/tokenizers}/unified_tokenizer.py +0 -0
  17. app.py +24 -895
  18. config/.env.example +51 -0
  19. config/requirements.txt +7 -0
  20. core/app.py +1057 -0
  21. health_checker.py β†’ core/health_checker.py +1 -1
  22. pages/admin/anti_abuse.html +107 -0
  23. pages/admin/base.html +419 -0
  24. pages/admin/bulk_operations.html +128 -0
  25. pages/admin/create_user.html +372 -0
  26. pages/admin/dashboard.html +502 -0
  27. pages/admin/edit_user.html +202 -0
  28. pages/admin/key_manager.html +357 -0
  29. pages/admin/list_users.html +247 -0
  30. pages/admin/login.html +84 -0
  31. pages/admin/model_families.html +402 -0
  32. pages/admin/stats.html +60 -0
  33. pages/admin/view_user.html +265 -0
  34. pages/admin_dashboard.html +587 -0
  35. {templates β†’ pages}/dashboard.html +11 -4
  36. requirements.txt +3 -1
  37. run.py +30 -0
  38. src/__init__.py +1 -0
  39. src/config/__init__.py +1 -0
  40. src/config/auth.py +76 -0
  41. src/middleware/__init__.py +1 -0
  42. src/middleware/auth.py +257 -0
  43. src/routes/__init__.py +1 -0
  44. src/routes/admin.py +379 -0
  45. src/routes/admin_web.py +507 -0
  46. src/routes/model_families_admin.py +249 -0
  47. src/services/__init__.py +1 -0
  48. src/services/event_logger.py +194 -0
  49. src/services/model_families.py +501 -0
  50. 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
- # Dashboard Configuration
2
- DASHBOARD_TITLE=NyanProxy
3
- DASHBOARD_SUBTITLE=Purr-fect monitoring for your AI service proxy!
4
- DASHBOARD_REFRESH_INTERVAL=30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- # Branding
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  BRAND_NAME=NyanProxy
8
  BRAND_EMOJI=🐱
9
- BRAND_DESCRIPTION=Meow-nificent AI Proxy!
 
 
10
 
11
- # API Keys (examples - replace with your actual keys)
12
- # OPENAI_API_KEYS=sk-your-key1,sk-your-key2
13
- # ANTHROPIC_API_KEYS=sk-ant-your-key1,sk-ant-your-key2
14
 
15
- # Server Configuration
16
- PORT=7860
 
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: AI Reverse Proxy
3
- emoji: πŸ”„
4
- colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
  app_file: app.py
8
  pinned: false
9
  ---
10
 
11
- # AI Reverse Proxy
12
 
13
- A simple reverse proxy for AI services that can be hosted on Hugging Face Spaces.
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
- from flask import Flask, request, jsonify, Response, render_template
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 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
- app.run(host='0.0.0.0', port=port, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- 'Authorization': f'Bearer {api_key}',
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>&copy; {{ 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')">&times;</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')">&times;</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>Passcode:</strong> Use any value (For now)</p>
 
 
 
 
 
 
 
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.prompt_tokens|default(0)}}</div>
496
- <div class="metric-label" style="font-size: 0.8em;">Prompt Tokens</div>
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;">Total Tokens</div>
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()