rnilkyway commited on
Commit
40afc1d
Β·
verified Β·
1 Parent(s): 18529e8

Upload folder using huggingface_hub

Browse files
app/config.py CHANGED
@@ -19,6 +19,7 @@ def _decrypt(value: str) -> str:
19
  if not aes_key:
20
  raise RuntimeError("Missing AES_KEY environment variable")
21
  try:
 
22
  key = base64.urlsafe_b64decode(aes_key)
23
  _, _, nonce_b64, cipher_b64 = value.split(":", 3)
24
  nonce = base64.urlsafe_b64decode(nonce_b64)
 
19
  if not aes_key:
20
  raise RuntimeError("Missing AES_KEY environment variable")
21
  try:
22
+ aes_key += "=" * (-len(aes_key) % 4)
23
  key = base64.urlsafe_b64decode(aes_key)
24
  _, _, nonce_b64, cipher_b64 = value.split(":", 3)
25
  nonce = base64.urlsafe_b64decode(nonce_b64)
app/data/api_keys.json ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "api-6ynmb50kph7orcv": {
3
+ "ip": "49.204.115.152",
4
+ "created": 1779295121,
5
+ "rpm": 4
6
+ },
7
+ "api-m1doivz912laas8": {
8
+ "ip": "185.183.33.13",
9
+ "created": 1779295175,
10
+ "rpm": 4
11
+ },
12
+ "api-nvi1zzdh6jk6m2n": {
13
+ "ip": "73.199.13.2",
14
+ "created": 1779305233,
15
+ "rpm": 4
16
+ },
17
+ "api-mtgq13knyfqa5kg": {
18
+ "ip": "87.235.72.80",
19
+ "created": 1779306560,
20
+ "rpm": 20
21
+ },
22
+ "api-5pkv96hvl3lvekx": {
23
+ "ip": "112.141.166.157",
24
+ "created": 1779307253,
25
+ "rpm": 4
26
+ },
27
+ "api-q0kqyw9ujjbpc6p": {
28
+ "ip": "171.79.59.48",
29
+ "created": 1779308409,
30
+ "rpm": 4
31
+ },
32
+ "api-qdyip624fyemx0x": {
33
+ "ip": "103.169.53.229",
34
+ "created": 1779308417,
35
+ "rpm": 4
36
+ },
37
+ "api-axfw072bcp8cpge": {
38
+ "ip": "88.241.79.14",
39
+ "created": 1779308743,
40
+ "rpm": 4
41
+ },
42
+ "api-cnns5zaioeoyz2h": {
43
+ "ip": "171.79.55.48",
44
+ "created": 1779309378,
45
+ "rpm": 4
46
+ },
47
+ "api-n6xmtr9vto88nqi": {
48
+ "ip": "138.199.7.229",
49
+ "created": 1779311977,
50
+ "rpm": 4
51
+ },
52
+ "api-zly9qxgnhh0yb00": {
53
+ "ip": "138.199.7.226",
54
+ "created": 1779312216,
55
+ "rpm": 4
56
+ },
57
+ "api-7u2yg8fan97pglk": {
58
+ "ip": "42.111.145.231",
59
+ "created": 1779347701,
60
+ "rpm": 4
61
+ },
62
+ "api-flaoa2ckj36mjsr": {
63
+ "ip": "95.1.126.214",
64
+ "created": 1779353574,
65
+ "rpm": 4
66
+ },
67
+ "api-0jsp9emdgezny4a": {
68
+ "ip": "88.229.222.114",
69
+ "created": 1779357368,
70
+ "rpm": 4
71
+ },
72
+ "api-5i88dgwxtjycjdj": {
73
+ "ip": "157.49.198.214",
74
+ "created": 1779359394,
75
+ "rpm": 4
76
+ },
77
+ "api-muozs3xm474rvn1": {
78
+ "ip": "78.190.249.157",
79
+ "created": 1779360625,
80
+ "rpm": 4
81
+ },
82
+ "api-0bpt9bap7y67c2c": {
83
+ "ip": "108.18.234.199",
84
+ "created": 1779394538,
85
+ "rpm": 4
86
+ },
87
+ "api-wqwolj17a9c6ca3": {
88
+ "ip": "49.204.116.8",
89
+ "created": 1779397943,
90
+ "rpm": 4
91
+ },
92
+ "api-wbhawdhpuqyrp2g": {
93
+ "ip": "47.60.40.235",
94
+ "created": 1779458372,
95
+ "rpm": 20
96
+ },
97
+ "api-o6aquyfeyw0lw4f": {
98
+ "ip": "114.8.227.150",
99
+ "created": 1779471144,
100
+ "rpm": 4
101
+ },
102
+ "api-os34e5ak7z7kpup": {
103
+ "ip": "95.5.119.10",
104
+ "created": 1779481321,
105
+ "rpm": 4
106
+ },
107
+ "api-33jvm6e1oocn2zq": {
108
+ "ip": "176.234.207.27",
109
+ "created": 1779521914,
110
+ "rpm": 4
111
+ },
112
+ "api-ajerid71amwqc7o": {
113
+ "ip": "49.204.137.8",
114
+ "created": 1779561515,
115
+ "rpm": 4
116
+ },
117
+ "api-o3ihnwbp70kud13": {
118
+ "ip": "88.246.193.149",
119
+ "created": 1779564512,
120
+ "rpm": 4
121
+ }
122
+ }
app/data/chat_logs/.~lock.2026-05-22.jsonl# ADDED
@@ -0,0 +1 @@
 
 
1
+ ,berkay,BerkayKral,23.05.2026 16:05,file:///home/berkay/.config/libreoffice/4;
app/data/chat_logs/2026-05-22.jsonl ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"timestamp": "2026-05-22T14:19:21.213884", "ip": "127.0.0.1", "model": "gpt-5", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
2
+ {"timestamp": "2026-05-22T14:19:29.642073", "ip": "127.0.0.1", "model": "gpt-5.1", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
3
+ {"timestamp": "2026-05-22T14:19:39.374349", "ip": "127.0.0.1", "model": "gpt-5.3", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
4
+ {"timestamp": "2026-05-22T14:19:49.222915", "ip": "127.0.0.1", "model": "gpt-5.4-gateway", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
5
+ {"timestamp": "2026-05-22T14:19:56.423839", "ip": "127.0.0.1", "model": "gpt-5.5-gateway", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
6
+ {"timestamp": "2026-05-22T14:20:04.793460", "ip": "127.0.0.1", "model": "o3", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
7
+ {"timestamp": "2026-05-22T14:20:17.794998", "ip": "127.0.0.1", "model": "o3-mini", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello."}
8
+ {"timestamp": "2026-05-22T14:20:29.882273", "ip": "127.0.0.1", "model": "o4-mini", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
9
+ {"timestamp": "2026-05-22T14:23:23.571003", "ip": "127.0.0.1", "model": "gpt-4o", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
10
+ {"timestamp": "2026-05-22T14:23:29.177315", "ip": "127.0.0.1", "model": "gpt-4.1-mini", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
11
+ {"timestamp": "2026-05-22T14:23:39.965982", "ip": "127.0.0.1", "model": "gpt-4.1-nano", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
12
+ {"timestamp": "2026-05-22T14:23:45.915690", "ip": "127.0.0.1", "model": "gpt-5-mini", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
13
+ {"timestamp": "2026-05-22T14:23:51.500554", "ip": "127.0.0.1", "model": "gpt-5-nano", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
14
+ {"timestamp": "2026-05-22T14:23:59.628258", "ip": "127.0.0.1", "model": "gpt-5-online", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
15
+ {"timestamp": "2026-05-22T14:24:04.627485", "ip": "127.0.0.1", "model": "claude-opus-4.7", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
16
+ {"timestamp": "2026-05-22T14:24:14.368115", "ip": "127.0.0.1", "model": "claude-opus-4.6-gateway", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
17
+ {"timestamp": "2026-05-22T14:24:20.627386", "ip": "127.0.0.1", "model": "claude-opus-4.5", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
18
+ {"timestamp": "2026-05-22T14:24:34.625092", "ip": "127.0.0.1", "model": "claude-opus-4.1", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi!"}
19
+ {"timestamp": "2026-05-22T14:24:40.799506", "ip": "127.0.0.1", "model": "claude-sonnet-4", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi!"}
20
+ {"timestamp": "2026-05-22T14:24:48.311807", "ip": "127.0.0.1", "model": "claude-sonnet-4.6-gateway", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi!"}
21
+ {"timestamp": "2026-05-22T14:24:57.561999", "ip": "127.0.0.1", "model": "gemini-2.5-pro", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello."}
22
+ {"timestamp": "2026-05-22T14:25:04.746413", "ip": "127.0.0.1", "model": "gemini-3-pro", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
23
+ {"timestamp": "2026-05-22T14:25:19.243930", "ip": "127.0.0.1", "model": "gemini-3.1-pro-gateway", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
24
+ {"timestamp": "2026-05-22T14:25:24.618241", "ip": "127.0.0.1", "model": "gemini-2.5-flash", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
25
+ {"timestamp": "2026-05-22T14:25:38.635329", "ip": "127.0.0.1", "model": "deepseek-v4-pro", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
26
+ {"timestamp": "2026-05-22T14:25:50.147293", "ip": "127.0.0.1", "model": "deepseek-v4-flash", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
27
+ {"timestamp": "2026-05-22T14:26:00.991754", "ip": "127.0.0.1", "model": "deepseek-r1", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
28
+ {"timestamp": "2026-05-22T14:27:02.499038", "ip": "127.0.0.1", "model": "deepseek-v3", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
29
+ {"timestamp": "2026-05-22T14:27:07.859817", "ip": "127.0.0.1", "model": "grok-4", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello."}
30
+ {"timestamp": "2026-05-22T14:27:15.919042", "ip": "127.0.0.1", "model": "grok-3", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
31
+ {"timestamp": "2026-05-22T14:27:21.789241", "ip": "127.0.0.1", "model": "qwen-3-max", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi."}
32
+ {"timestamp": "2026-05-22T14:27:37.435616", "ip": "127.0.0.1", "model": "qwen-qwq-32b", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello."}
33
+ {"timestamp": "2026-05-22T14:27:43.835269", "ip": "127.0.0.1", "model": "kimi-k2", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
34
+ {"timestamp": "2026-05-22T14:27:52.077500", "ip": "127.0.0.1", "model": "llama-3.3-70b-versatile", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hello"}
35
+ {"timestamp": "2026-05-22T14:49:31.725453", "ip": "127.0.0.1", "model": "gpt-5", "key_type": "master", "input": [{"role": "user", "content": "What is the weather in Tokyo? Use the tool."}], "output": ""}
36
+ {"timestamp": "2026-05-22T14:51:53.157473", "ip": "127.0.0.1", "model": "gpt-5", "key_type": "master", "input": [{"role": "user", "content": "What is the weather in Tokyo? Use the tool."}], "output": ""}
37
+ {"timestamp": "2026-05-22T16:46:18.154483", "ip": "127.0.0.1", "model": "gpt-5", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi"}
38
+ {"timestamp": "2026-05-22T16:46:20.413839", "ip": "127.0.0.1", "model": "gpt-5.4", "key_type": "master", "input": [{"role": "user", "content": "Say hi in one word."}], "output": "Hi!"}
app/data/providers.json ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "localtest-0b3498": {
3
+ "name": "LocalTest",
4
+ "base_url": "http://localhost:1111/v1",
5
+ "keys": [
6
+ "api-oldbutgodzenoth"
7
+ ],
8
+ "models": [
9
+ "claude-opus-4.6",
10
+ "claude-sonnet-4.6",
11
+ "deepseek-v4-pro",
12
+ "gemini-3.1-pro",
13
+ "glm-5.1",
14
+ "gpt-5.3-codex",
15
+ "gpt-5.4",
16
+ "gpt-5.5",
17
+ "gpt-5.5-cyber",
18
+ "grok-4.2",
19
+ "kimi-k2.6",
20
+ "mimo-2.5-pro",
21
+ "minimax-m2.7",
22
+ "qwen-3.6-max",
23
+ "qwen-3.6-plus",
24
+ "qwen-3.7-max",
25
+ "step-3.5-flash",
26
+ "claude-opus-4.1",
27
+ "claude-opus-4.5",
28
+ "claude-opus-4.6-gateway",
29
+ "claude-opus-4.7",
30
+ "claude-sonnet-4",
31
+ "claude-sonnet-4.6-gateway",
32
+ "deepseek-r1",
33
+ "deepseek-v3",
34
+ "deepseek-v4-flash",
35
+ "gemini-2.5-flash",
36
+ "gemini-2.5-pro",
37
+ "gemini-3-pro",
38
+ "gemini-3.1-pro-gateway",
39
+ "gpt-4.1-mini",
40
+ "gpt-4.1-nano",
41
+ "gpt-4o",
42
+ "gpt-5",
43
+ "gpt-5-mini",
44
+ "gpt-5-nano",
45
+ "gpt-5-online",
46
+ "gpt-5.1",
47
+ "gpt-5.3",
48
+ "gpt-5.4-gateway",
49
+ "gpt-5.5-gateway",
50
+ "grok-3",
51
+ "grok-4",
52
+ "kimi-k2",
53
+ "llama-3.3-70b-versatile",
54
+ "o3",
55
+ "o3-mini",
56
+ "o4-mini",
57
+ "qwen-3-max",
58
+ "qwen-qwq-32b",
59
+ "gpt-image-1",
60
+ "gpt-image-2"
61
+ ],
62
+ "added_at": 1779570172
63
+ },
64
+ "mistral-7503f8": {
65
+ "name": "Mistral",
66
+ "base_url": "https://api.mistral.ai/v1",
67
+ "keys": [
68
+ "hajUBLnpbKBo5pCEhcr7SZYHrnRvnQxZ"
69
+ ],
70
+ "models": [
71
+ "mistral-medium-2505",
72
+ "mistral-medium-2508",
73
+ "mistral-medium-latest",
74
+ "mistral-medium",
75
+ "mistral-vibe-cli-with-tools",
76
+ "open-mistral-nemo",
77
+ "open-mistral-nemo-2407",
78
+ "mistral-tiny-2407",
79
+ "mistral-tiny-latest",
80
+ "codestral-2508",
81
+ "codestral-latest",
82
+ "devstral-2512",
83
+ "devstral-medium-latest",
84
+ "devstral-latest",
85
+ "mistral-small-2603",
86
+ "mistral-small-latest",
87
+ "mistral-vibe-cli-fast",
88
+ "magistral-small-latest",
89
+ "magistral-medium-2509",
90
+ "magistral-medium-latest",
91
+ "voxtral-small-2507",
92
+ "voxtral-small-latest",
93
+ "labs-leanstral-2603",
94
+ "mistral-large-2512",
95
+ "mistral-large-latest",
96
+ "mistral-large-2512",
97
+ "mistral-large-latest",
98
+ "ministral-3b-2512",
99
+ "ministral-3b-latest",
100
+ "ministral-8b-2512",
101
+ "ministral-8b-latest",
102
+ "ministral-14b-2512",
103
+ "ministral-14b-latest",
104
+ "mistral-medium-3-5",
105
+ "mistral-medium-3.5",
106
+ "mistral-medium-3",
107
+ "mistral-medium-2604",
108
+ "mistral-medium-c21211-r0-75",
109
+ "mistral-vibe-cli-latest",
110
+ "mistral-large-2411",
111
+ "pixtral-large-2411",
112
+ "pixtral-large-latest",
113
+ "mistral-large-pixtral-2411",
114
+ "devstral-small-2507",
115
+ "devstral-medium-2507",
116
+ "voxtral-mini-2507",
117
+ "voxtral-mini-latest",
118
+ "magistral-small-2509",
119
+ "mistral-small-2506",
120
+ "mistral-embed-2312",
121
+ "mistral-embed",
122
+ "codestral-embed",
123
+ "codestral-embed-2505",
124
+ "mistral-moderation-2603",
125
+ "mistral-moderation-2411",
126
+ "mistral-moderation-latest",
127
+ "mistral-ocr-2512",
128
+ "mistral-ocr-latest",
129
+ "mistral-ocr-2505",
130
+ "voxtral-mini-2602",
131
+ "voxtral-mini-latest",
132
+ "voxtral-mini-transcribe-realtime-2602",
133
+ "voxtral-mini-realtime-2602",
134
+ "voxtral-mini-realtime-latest",
135
+ "voxtral-mini-transcribe-2507",
136
+ "voxtral-mini-2507",
137
+ "voxtral-mini-tts-2603",
138
+ "voxtral-mini-tts-latest"
139
+ ],
140
+ "added_at": 1779570266
141
+ }
142
+ }
app/index.py CHANGED
@@ -46,9 +46,14 @@ MAX_CONTINUATIONS = 0
46
 
47
  RPM_LIMIT = 4
48
  KEYS_FILE = os.path.join(os.path.dirname(__file__), "data", "api_keys.json")
 
49
 
50
  _rate_limits: dict = defaultdict(list)
51
 
 
 
 
 
52
  # ── Persistent Storage: GitHub Gist + local fallback ─────────────────────────
53
 
54
  _github_token = os.environ.get("GITHUB_TOKEN", GITHUB_TOKEN)
@@ -396,6 +401,7 @@ async def lifespan(application: FastAPI):
396
  # Preload keys from Gist at startup
397
  _load_keys()
398
  logger.info("Preloaded %d API keys from Gist", len(_keys_cache))
 
399
  await _fetch_upstream_models()
400
  yield
401
 
@@ -930,7 +936,7 @@ async def dashboard_usage(request: Request):
930
  html = html.replace("MODEL_TOTALS", model_totals)
931
  html = html.replace("IP_ROWS", ip_rows)
932
 
933
- return HTMLResponse(html)
934
 
935
 
936
  @app.get("/v1/upstream-models")
@@ -956,7 +962,7 @@ async def admin_list_keys(request: Request):
956
  if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
957
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
958
 
959
- keys = load_keys_from_gist()
960
  admin_keys = []
961
  for key, data in keys.items():
962
  if data.get("admin_created"):
@@ -969,6 +975,68 @@ async def admin_list_keys(request: Request):
969
  })
970
  return {"keys": admin_keys}
971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  @app.post("/admin/keys")
973
  async def admin_create_key(request: Request, body: AdminKeyRequest):
974
  """Create a new IP-bound API key. Requires MASTER_KEY auth."""
@@ -985,7 +1053,7 @@ async def admin_create_key(request: Request, body: AdminKeyRequest):
985
  raise HTTPException(status_code=400, detail={"error": {"message": "Invalid IP address format"}})
986
 
987
  # Check duplicate
988
- keys = load_keys_from_gist()
989
  for k, data in keys.items():
990
  if data.get("ip") == body.ip and data.get("admin_created"):
991
  raise HTTPException(status_code=409, detail={"error": {"message": f"IP {body.ip} already has key {k}"}})
@@ -999,7 +1067,7 @@ async def admin_create_key(request: Request, body: AdminKeyRequest):
999
  "status": "active",
1000
  "admin_created": True,
1001
  }
1002
- save_keys_to_gist(keys)
1003
 
1004
  return {
1005
  "key": new_key,
@@ -1008,6 +1076,45 @@ async def admin_create_key(request: Request, body: AdminKeyRequest):
1008
  "created_at": keys[new_key]["created_at"],
1009
  }
1010
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  @app.delete("/admin/keys/{key_id}")
1012
  async def admin_revoke_key(request: Request, key_id: str):
1013
  """Revoke an admin-created API key. Requires MASTER_KEY auth."""
@@ -1015,16 +1122,226 @@ async def admin_revoke_key(request: Request, key_id: str):
1015
  if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1016
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1017
 
1018
- keys = load_keys_from_gist()
1019
  if key_id not in keys:
1020
  raise HTTPException(status_code=404, detail={"error": {"message": "Key not found"}})
1021
  if not keys[key_id].get("admin_created"):
1022
  raise HTTPException(status_code=403, detail={"error": {"message": "Cannot revoke non-admin keys"}})
1023
 
1024
  del keys[key_id]
1025
- save_keys_to_gist(keys)
1026
  return {"success": True}
1027
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1028
  # ── Image Generation Endpoint ─────────────────────────────────────────────────
1029
 
1030
  @app.post("/v1/images/generations")
@@ -1119,24 +1436,25 @@ async def get_my_key(request: Request, cf_token: str = None):
1119
  detail={"error": {"message": "VPN/Proxy detected. Please turn off VPN and try again.", "type": "verification_error", "code": "vpn_blocked", "provider": vpn_result["provider"]}}
1120
  )
1121
 
1122
- # ── Step 2: New user β€” require Turnstile verification ──
1123
- if not cf_token:
1124
- raise HTTPException(status_code=403, detail={"error": {"message": "Verification required", "type": "verification_error", "code": "missing_cf_token"}})
1125
-
1126
- # Verify Turnstile token with Cloudflare
1127
- try:
1128
- verify_resp = await make_client().post(
1129
- "https://challenges.cloudflare.com/turnstile/v0/siteverify",
1130
- data={"response": cf_token, "secret": TURNSTILE_SECRET_KEY},
1131
- )
1132
- verify_data = verify_resp.json()
1133
- if not verify_data.get("success"):
1134
- raise HTTPException(status_code=403, detail={"error": {"message": "Verification failed", "type": "verification_error", "code": "invalid_cf_token"}})
1135
- except HTTPException:
1136
- raise
1137
- except Exception as e:
1138
- logger.warning("Turnstile verification error: %s", e)
1139
- raise HTTPException(status_code=500, detail={"error": {"message": "Verification service error", "type": "server_error", "code": "cf_verification_error"}})
 
1140
 
1141
  # ── Step 3: Auto-generate key for verified user ──
1142
  new_key = generate_api_key()
 
46
 
47
  RPM_LIMIT = 4
48
  KEYS_FILE = os.path.join(os.path.dirname(__file__), "data", "api_keys.json")
49
+ PROVIDERS_FILE = os.path.join(os.path.dirname(__file__), "data", "providers.json")
50
 
51
  _rate_limits: dict = defaultdict(list)
52
 
53
+ # ── Dynamic Providers (runtime-imported OpenAI-compatible backends) ─────────
54
+ _dynamic_providers: dict = {} # provider_id -> {name, base_url, keys, models, added_at}
55
+
56
+
57
  # ── Persistent Storage: GitHub Gist + local fallback ─────────────────────────
58
 
59
  _github_token = os.environ.get("GITHUB_TOKEN", GITHUB_TOKEN)
 
401
  # Preload keys from Gist at startup
402
  _load_keys()
403
  logger.info("Preloaded %d API keys from Gist", len(_keys_cache))
404
+ _load_providers()
405
  await _fetch_upstream_models()
406
  yield
407
 
 
936
  html = html.replace("MODEL_TOTALS", model_totals)
937
  html = html.replace("IP_ROWS", ip_rows)
938
 
939
+ return HTMLResponse(html, headers={"Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache"})
940
 
941
 
942
  @app.get("/v1/upstream-models")
 
962
  if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
963
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
964
 
965
+ keys = _load_keys()
966
  admin_keys = []
967
  for key, data in keys.items():
968
  if data.get("admin_created"):
 
975
  })
976
  return {"keys": admin_keys}
977
 
978
+ @app.get("/admin/stats")
979
+ async def admin_stats(request: Request):
980
+ """Return aggregated stats for dashboard charts. Requires MASTER_KEY auth."""
981
+ auth = request.headers.get("authorization", "")
982
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
983
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
984
+
985
+ from datetime import datetime, timedelta
986
+ # Load local chat logs
987
+ chat_logs = []
988
+ try:
989
+ if os.path.isdir(CHAT_LOG_DIR):
990
+ for fname in sorted(os.listdir(CHAT_LOG_DIR)):
991
+ if fname.endswith(".jsonl") and not fname.endswith("_upload.jsonl"):
992
+ fpath = os.path.join(CHAT_LOG_DIR, fname)
993
+ try:
994
+ with open(fpath) as f:
995
+ for line in f:
996
+ line = line.strip()
997
+ if line:
998
+ try:
999
+ chat_logs.append(json.loads(line))
1000
+ except json.JSONDecodeError:
1001
+ pass
1002
+ except Exception:
1003
+ pass
1004
+ except Exception as e:
1005
+ logger.warning("Failed to load local chat logs for stats: %s", e)
1006
+
1007
+ # Build daily buckets for last 7 days
1008
+ today = datetime.utcnow().date()
1009
+ daily_counts = [0] * 7
1010
+ model_counts = {}
1011
+ rate_limited = 0
1012
+ for log in chat_logs:
1013
+ ts = log.get("timestamp", "")
1014
+ try:
1015
+ log_date = datetime.fromisoformat(ts.replace("Z", "+00:00")).date() if ts else None
1016
+ except Exception:
1017
+ log_date = None
1018
+ if log_date:
1019
+ days_ago = (today - log_date).days
1020
+ if 0 <= days_ago < 7:
1021
+ daily_counts[6 - days_ago] += 1
1022
+ model = log.get("model", "unknown")
1023
+ model_counts[model] = model_counts.get(model, 0) + 1
1024
+ if log.get("rate_limited"):
1025
+ rate_limited += 1
1026
+
1027
+ # Top 5 models
1028
+ top_models = sorted(model_counts.items(), key=lambda x: x[1], reverse=True)[:5]
1029
+ # Pad to 5 if needed
1030
+ while len(top_models) < 5:
1031
+ top_models.append(("other", 0))
1032
+
1033
+ return {
1034
+ "total_requests": len(chat_logs),
1035
+ "rate_limited": rate_limited,
1036
+ "requests_by_day": daily_counts,
1037
+ "models": [{"name": m[0], "count": m[1]} for m in top_models],
1038
+ }
1039
+
1040
  @app.post("/admin/keys")
1041
  async def admin_create_key(request: Request, body: AdminKeyRequest):
1042
  """Create a new IP-bound API key. Requires MASTER_KEY auth."""
 
1053
  raise HTTPException(status_code=400, detail={"error": {"message": "Invalid IP address format"}})
1054
 
1055
  # Check duplicate
1056
+ keys = _load_keys()
1057
  for k, data in keys.items():
1058
  if data.get("ip") == body.ip and data.get("admin_created"):
1059
  raise HTTPException(status_code=409, detail={"error": {"message": f"IP {body.ip} already has key {k}"}})
 
1067
  "status": "active",
1068
  "admin_created": True,
1069
  }
1070
+ _save_keys(keys)
1071
 
1072
  return {
1073
  "key": new_key,
 
1076
  "created_at": keys[new_key]["created_at"],
1077
  }
1078
 
1079
+ @app.get("/admin/models")
1080
+ async def admin_models(request: Request):
1081
+ """Return all currently routed models with provider info. Requires MASTER_KEY auth."""
1082
+ auth = request.headers.get("authorization", "")
1083
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1084
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1085
+
1086
+ # Build dynamic provider lookup: url -> provider name
1087
+ prov_lookup = {}
1088
+ for pid, prov in _dynamic_providers.items():
1089
+ base_url = prov["base_url"].rstrip("/")
1090
+ prov_lookup[f"{base_url}/chat/completions"] = prov["name"]
1091
+ for m in prov.get("models", []):
1092
+ prov_lookup.setdefault("model:" + m, prov["name"])
1093
+
1094
+ models = []
1095
+ for model_id in sorted(UPSTREAMS.keys()):
1096
+ upstreams = UPSTREAMS[model_id]
1097
+ urls = [u["url"] for u in upstreams]
1098
+ provider = "built-in"
1099
+ for u in urls:
1100
+ if u in prov_lookup:
1101
+ provider = prov_lookup[u]
1102
+ break
1103
+ if f"model:{model_id}" in prov_lookup:
1104
+ provider = prov_lookup[f"model:{model_id}"]
1105
+ break
1106
+ models.append({
1107
+ "id": model_id,
1108
+ "upstreams": len(upstreams),
1109
+ "provider": provider,
1110
+ })
1111
+
1112
+ for m_id in ["gpt-image-1", "gpt-image-2"]:
1113
+ if not any(m["id"] == m_id for m in models):
1114
+ models.append({"id": m_id, "upstreams": 1, "provider": "image-backend"})
1115
+
1116
+ return {"models": models, "total": len(models)}
1117
+
1118
  @app.delete("/admin/keys/{key_id}")
1119
  async def admin_revoke_key(request: Request, key_id: str):
1120
  """Revoke an admin-created API key. Requires MASTER_KEY auth."""
 
1122
  if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1123
  raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1124
 
1125
+ keys = _load_keys()
1126
  if key_id not in keys:
1127
  raise HTTPException(status_code=404, detail={"error": {"message": "Key not found"}})
1128
  if not keys[key_id].get("admin_created"):
1129
  raise HTTPException(status_code=403, detail={"error": {"message": "Cannot revoke non-admin keys"}})
1130
 
1131
  del keys[key_id]
1132
+ _save_keys(keys)
1133
  return {"success": True}
1134
 
1135
+
1136
+ # ── Dynamic Provider Import ─────────────────────────────────────────────────
1137
+
1138
+ class ProviderImportRequest(BaseModel):
1139
+ name: str
1140
+ base_url: str # e.g. https://api.example.com/v1
1141
+ keys: list # list of API keys (strings)
1142
+
1143
+
1144
+ def _load_providers():
1145
+ """Load dynamic providers from disk and inject into UPSTREAMS."""
1146
+ global _dynamic_providers
1147
+ try:
1148
+ if os.path.exists(PROVIDERS_FILE):
1149
+ with open(PROVIDERS_FILE, "r") as f:
1150
+ _dynamic_providers = json.load(f) or {}
1151
+ for pid, prov in _dynamic_providers.items():
1152
+ _register_provider_in_upstreams(prov)
1153
+ logger.info("Loaded %d dynamic providers", len(_dynamic_providers))
1154
+ except Exception as e:
1155
+ logger.warning("Failed to load providers: %s", e)
1156
+ _dynamic_providers = {}
1157
+
1158
+
1159
+ def _save_providers():
1160
+ """Persist dynamic providers to disk."""
1161
+ try:
1162
+ os.makedirs(os.path.dirname(PROVIDERS_FILE), exist_ok=True)
1163
+ with open(PROVIDERS_FILE, "w") as f:
1164
+ json.dump(_dynamic_providers, f, indent=2)
1165
+ except Exception as e:
1166
+ logger.warning("Failed to save providers: %s", e)
1167
+
1168
+
1169
+ def _register_provider_in_upstreams(prov: dict):
1170
+ """Inject a provider's models + keys into the global UPSTREAMS/UPSTREAM_API_KEYS."""
1171
+ base_url = prov["base_url"].rstrip("/")
1172
+ chat_url = f"{base_url}/chat/completions"
1173
+ keys = prov.get("keys", [])
1174
+ models = prov.get("models", [])
1175
+
1176
+ # Register API keys β€” use first key as primary, others rotate via round-robin
1177
+ for i, key in enumerate(keys):
1178
+ UPSTREAM_API_KEYS.setdefault(chat_url, key)
1179
+
1180
+ # Register models β€” each model routes to this provider
1181
+ for model_id in models:
1182
+ # Don't override built-in models
1183
+ if model_id in UPSTREAMS:
1184
+ continue
1185
+ UPSTREAMS[model_id] = [{"url": chat_url, "model": model_id}]
1186
+
1187
+
1188
+ @app.get("/admin/providers")
1189
+ async def admin_list_providers(request: Request):
1190
+ """List all dynamically imported providers."""
1191
+ auth = request.headers.get("authorization", "")
1192
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1193
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1194
+
1195
+ out = []
1196
+ for pid, prov in _dynamic_providers.items():
1197
+ out.append({
1198
+ "id": pid,
1199
+ "name": prov.get("name", ""),
1200
+ "base_url": prov.get("base_url", ""),
1201
+ "key_count": len(prov.get("keys", [])),
1202
+ "models": prov.get("models", []),
1203
+ "added_at": prov.get("added_at", 0),
1204
+ })
1205
+ return {"providers": out}
1206
+
1207
+
1208
+ @app.post("/admin/providers/preview")
1209
+ async def admin_preview_provider(request: Request, body: ProviderImportRequest):
1210
+ """Preview models available from an OpenAI-compatible provider (no persistence)."""
1211
+ auth = request.headers.get("authorization", "")
1212
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1213
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1214
+
1215
+ base_url = body.base_url.rstrip("/")
1216
+ models_url = f"{base_url}/models"
1217
+
1218
+ headers = {"Content-Type": "application/json"}
1219
+ if body.keys:
1220
+ headers["Authorization"] = f"Bearer {body.keys[0]}"
1221
+
1222
+ try:
1223
+ async with httpx.AsyncClient(timeout=15) as client:
1224
+ resp = await client.get(models_url, headers=headers)
1225
+ except Exception as e:
1226
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Failed to reach provider: {e}"}})
1227
+
1228
+ if resp.status_code != 200:
1229
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Provider /models returned {resp.status_code}"}})
1230
+
1231
+ try:
1232
+ data = resp.json()
1233
+ except Exception:
1234
+ raise HTTPException(status_code=502, detail={"error": {"message": "Invalid JSON from provider"}})
1235
+
1236
+ # Support both OpenAI format {"data": [...]} and plain list
1237
+ if isinstance(data, dict) and "data" in data:
1238
+ models = [m.get("id") for m in data["data"] if m.get("id")]
1239
+ elif isinstance(data, list):
1240
+ models = [m.get("id") if isinstance(m, dict) else str(m) for m in data]
1241
+ else:
1242
+ raise HTTPException(status_code=502, detail={"error": {"message": "Unexpected response shape from /models"}})
1243
+
1244
+ models = [m for m in models if m]
1245
+ return {
1246
+ "name": body.name,
1247
+ "base_url": base_url,
1248
+ "model_count": len(models),
1249
+ "models": models,
1250
+ }
1251
+
1252
+
1253
+ @app.post("/admin/providers/import")
1254
+ async def admin_import_provider(request: Request, body: ProviderImportRequest):
1255
+ """Import an OpenAI-compatible provider: fetch models, register routing, persist."""
1256
+ auth = request.headers.get("authorization", "")
1257
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1258
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1259
+
1260
+ if not body.keys:
1261
+ raise HTTPException(status_code=400, detail={"error": {"message": "At least one API key required"}})
1262
+
1263
+ base_url = body.base_url.rstrip("/")
1264
+ models_url = f"{base_url}/models"
1265
+
1266
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {body.keys[0]}"}
1267
+
1268
+ try:
1269
+ async with httpx.AsyncClient(timeout=15) as client:
1270
+ resp = await client.get(models_url, headers=headers)
1271
+ except Exception as e:
1272
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Failed to reach provider: {e}"}})
1273
+
1274
+ if resp.status_code != 200:
1275
+ raise HTTPException(status_code=502, detail={"error": {"message": f"Provider /models returned {resp.status_code}"}})
1276
+
1277
+ data = resp.json()
1278
+ if isinstance(data, dict) and "data" in data:
1279
+ models = [m.get("id") for m in data["data"] if m.get("id")]
1280
+ elif isinstance(data, list):
1281
+ models = [m.get("id") if isinstance(m, dict) else str(m) for m in data]
1282
+ else:
1283
+ models = []
1284
+
1285
+ models = [m for m in models if m]
1286
+
1287
+ pid = body.name.lower().replace(" ", "-") + "-" + uuid.uuid4().hex[:6]
1288
+ prov = {
1289
+ "name": body.name,
1290
+ "base_url": base_url,
1291
+ "keys": list(body.keys),
1292
+ "models": models,
1293
+ "added_at": int(time.time()),
1294
+ }
1295
+ _dynamic_providers[pid] = prov
1296
+ _register_provider_in_upstreams(prov)
1297
+ _save_providers()
1298
+
1299
+ # Refresh global available models list
1300
+ global _available_models
1301
+ _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
1302
+ logger.info("Imported provider %s with %d models", pid, len(models))
1303
+
1304
+ return {
1305
+ "id": pid,
1306
+ "name": prov["name"],
1307
+ "base_url": base_url,
1308
+ "key_count": len(body.keys),
1309
+ "model_count": len(models),
1310
+ "models": models,
1311
+ }
1312
+
1313
+
1314
+ @app.delete("/admin/providers/{provider_id}")
1315
+ async def admin_delete_provider(request: Request, provider_id: str):
1316
+ """Remove a dynamic provider and unregister its routes."""
1317
+ auth = request.headers.get("authorization", "")
1318
+ if not auth.startswith("Bearer ") or auth.replace("Bearer ", "") != MASTER_KEY:
1319
+ raise HTTPException(status_code=401, detail={"error": {"message": "Invalid master key"}})
1320
+
1321
+ if provider_id not in _dynamic_providers:
1322
+ raise HTTPException(status_code=404, detail={"error": {"message": "Provider not found"}})
1323
+
1324
+ prov = _dynamic_providers.pop(provider_id)
1325
+ base_url = prov["base_url"].rstrip("/")
1326
+ chat_url = f"{base_url}/chat/completions"
1327
+
1328
+ # Remove model routes that pointed to this provider
1329
+ for model_id in prov.get("models", []):
1330
+ if model_id in UPSTREAMS:
1331
+ UPSTREAMS[model_id] = [u for u in UPSTREAMS[model_id] if u.get("url") != chat_url]
1332
+ if not UPSTREAMS[model_id]:
1333
+ del UPSTREAMS[model_id]
1334
+
1335
+ # Remove API key mapping if no other provider uses it
1336
+ if chat_url in UPSTREAM_API_KEYS:
1337
+ del UPSTREAM_API_KEYS[chat_url]
1338
+
1339
+ _save_providers()
1340
+ global _available_models
1341
+ _available_models = list(UPSTREAMS.keys()) + ["gpt-image-1", "gpt-image-2"]
1342
+ return {"success": True}
1343
+
1344
+
1345
  # ── Image Generation Endpoint ─────────────────────────────────────────────────
1346
 
1347
  @app.post("/v1/images/generations")
 
1436
  detail={"error": {"message": "VPN/Proxy detected. Please turn off VPN and try again.", "type": "verification_error", "code": "vpn_blocked", "provider": vpn_result["provider"]}}
1437
  )
1438
 
1439
+ # ── Step 2: Turnstile verification (optional when TURNSTILE_SECRET_KEY not configured) ──
1440
+ if TURNSTILE_SECRET_KEY:
1441
+ if not cf_token:
1442
+ raise HTTPException(status_code=403, detail={"error": {"message": "Verification required", "type": "verification_error", "code": "missing_cf_token"}})
1443
+ try:
1444
+ verify_resp = await make_client().post(
1445
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
1446
+ data={"response": cf_token, "secret": TURNSTILE_SECRET_KEY},
1447
+ )
1448
+ verify_data = verify_resp.json()
1449
+ if not verify_data.get("success"):
1450
+ raise HTTPException(status_code=403, detail={"error": {"message": "Verification failed", "type": "verification_error", "code": "invalid_cf_token"}})
1451
+ except HTTPException:
1452
+ raise
1453
+ except Exception as e:
1454
+ logger.warning("Turnstile verification error: %s", e)
1455
+ raise HTTPException(status_code=500, detail={"error": {"message": "Verification service error", "type": "server_error", "code": "cf_verification_error"}})
1456
+ else:
1457
+ logger.info("Turnstile disabled (no TURNSTILE_SECRET_KEY), allowing key generation for IP %s", client_ip)
1458
 
1459
  # ── Step 3: Auto-generate key for verified user ──
1460
  new_key = generate_api_key()
app/templates/dashboard.html CHANGED
@@ -94,6 +94,8 @@
94
  </div>
95
  <nav class="hidden md:flex items-center gap-6 text-sm">
96
  <a href="#stats" class="text-amber-400 font-medium">Dashboard</a>
 
 
97
  <a href="#generator" class="text-slate-400 hover:text-slate-200 transition">Keys</a>
98
  <a href="#charts" class="text-slate-400 hover:text-slate-200 transition">Analytics</a>
99
  <button id="logout-btn" class="text-slate-500 hover:text-red-400 transition text-xs font-mono">logout</button>
@@ -111,7 +113,7 @@
111
  </div>
112
 
113
  <!-- Stats Grid -->
114
- <section id="stats" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
115
  <div class="glass glass-hover rounded-xl p-6 transition-all">
116
  <div class="flex items-center justify-between mb-3">
117
  <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
@@ -144,6 +146,90 @@
144
  <div class="text-3xl font-bold font-mono" id="stat-ratelimited">β€”</div>
145
  <p class="text-xs text-slate-400 mt-1">Rate Limited</p>
146
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </section>
148
 
149
  <!-- Key Generator -->
@@ -265,6 +351,8 @@
265
  if (masterKey) {
266
  showDashboard();
267
  loadKeys();
 
 
268
  } else {
269
  showAuth();
270
  }
@@ -283,6 +371,7 @@
283
  authError.classList.add('hidden');
284
  showDashboard();
285
  loadKeys();
 
286
  } else {
287
  authError.textContent = 'Invalid master key';
288
  authError.classList.remove('hidden');
@@ -323,8 +412,8 @@
323
  if (!r || !r.ok) return;
324
  const data = await r.json();
325
  keysData = data.keys || [];
326
- renderStats();
327
  renderTable();
 
328
  renderCharts();
329
  $('last-sync').textContent = new Date().toLocaleTimeString();
330
  } catch (e) {
@@ -332,12 +421,23 @@
332
  }
333
  }
334
 
 
 
 
 
 
 
 
 
 
335
  function renderStats() {
336
  $('stat-total').textContent = keysData.length;
337
  const active = keysData.filter(k => k.status === 'active');
338
  $('stat-active').textContent = new Set(active.map(k => k.ip)).size;
339
- $('stat-requests').textContent = 'β€”';
340
- $('stat-ratelimited').textContent = 'β€”';
 
 
341
  }
342
 
343
  function maskKey(k) {
@@ -465,13 +565,15 @@
465
  return d.toLocaleDateString(undefined, { weekday: 'short' });
466
  });
467
 
 
 
468
  if (chartRequests) chartRequests.destroy();
469
  chartRequests = new Chart($('chart-requests'), {
470
  type: 'line',
471
  data: {
472
  labels: days,
473
  datasets: [{
474
- label: 'Requests', data: [0,0,0,0,0,0,0],
475
  borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.1)',
476
  fill: true, tension: 0.4, pointRadius: 4, pointBackgroundColor: '#f59e0b',
477
  }]
@@ -479,22 +581,206 @@
479
  options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { grid: { display: false } } } }
480
  });
481
 
 
 
 
 
482
  if (chartModels) chartModels.destroy();
483
- chartModels = new Chart($('chart-models'), {
484
- type: 'doughnut',
485
- data: {
486
- labels: ['gpt-4', 'claude', 'gemini', 'deepseek', 'other'],
487
- datasets: [{
488
- data: [30, 25, 20, 15, 10],
489
- backgroundColor: ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6', '#64748b'],
490
- borderColor: '#0a0a0f', borderWidth: 2,
491
- }]
492
- },
493
- options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { family: "'JetBrains Mono'", size: 11 }, padding: 12 } } } }
494
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  }
 
 
496
 
497
  initAuth();
 
498
  })();
499
  </script>
500
  </body>
 
94
  </div>
95
  <nav class="hidden md:flex items-center gap-6 text-sm">
96
  <a href="#stats" class="text-amber-400 font-medium">Dashboard</a>
97
+ <a href="#providers" class="text-slate-400 hover:text-slate-200 transition">Providers</a>
98
+ <a href="#models" class="text-slate-400 hover:text-slate-200 transition">Models</a>
99
  <a href="#generator" class="text-slate-400 hover:text-slate-200 transition">Keys</a>
100
  <a href="#charts" class="text-slate-400 hover:text-slate-200 transition">Analytics</a>
101
  <button id="logout-btn" class="text-slate-500 hover:text-red-400 transition text-xs font-mono">logout</button>
 
113
  </div>
114
 
115
  <!-- Stats Grid -->
116
+ <section id="stats" class="grid grid-cols-2 lg:grid-cols-5 gap-4">
117
  <div class="glass glass-hover rounded-xl p-6 transition-all">
118
  <div class="flex items-center justify-between mb-3">
119
  <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
 
146
  <div class="text-3xl font-bold font-mono" id="stat-ratelimited">β€”</div>
147
  <p class="text-xs text-slate-400 mt-1">Rate Limited</p>
148
  </div>
149
+ <div class="glass glass-hover rounded-xl p-6 transition-all">
150
+ <div class="flex items-center justify-between mb-3">
151
+ <svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
152
+ <span class="text-xs font-mono text-slate-500">MODELS</span>
153
+ </div>
154
+ <div class="text-3xl font-bold font-mono" id="stat-models">β€”</div>
155
+ <p class="text-xs text-slate-400 mt-1">Available Models</p>
156
+ </div>
157
+ </section>
158
+
159
+ <!-- Models Catalog -->
160
+ <section id="models" class="glass rounded-2xl p-8">
161
+ <div class="flex items-center justify-between mb-6">
162
+ <div>
163
+ <h3 class="text-xl font-bold">Models Catalog</h3>
164
+ <p class="text-sm text-slate-400 mt-1">All models currently routed through the gateway. Updated on startup and provider import.</p>
165
+ </div>
166
+ <span class="text-xs font-mono text-blue-500 bg-blue-500/10 px-3 py-1 rounded-full border border-blue-500/20">LIVE</span>
167
+ </div>
168
+ <div class="mb-4 flex items-center gap-3">
169
+ <input id="models-search" type="text" placeholder="Search models..."
170
+ class="flex-1 bg-black/30 border border-white/10 rounded-lg px-4 py-2 text-sm font-mono focus:outline-none focus:border-blue-500/50 placeholder:text-slate-600">
171
+ <span id="models-count" class="text-xs font-mono text-slate-500">0 models</span>
172
+ </div>
173
+ <div id="models-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 max-h-96 overflow-y-auto pr-2">
174
+ <p class="text-xs text-slate-600 font-mono col-span-full">Loading models...</p>
175
+ </div>
176
+ </section>
177
+
178
+ <!-- Provider Import -->
179
+ <section id="providers" class="glass rounded-2xl p-8">
180
+ <div class="flex items-center justify-between mb-6">
181
+ <div>
182
+ <h3 class="text-xl font-bold">Import Provider</h3>
183
+ <p class="text-sm text-slate-400 mt-1">Connect any OpenAI-compatible API endpoint. Paste base URL + keys, models are fetched automatically.</p>
184
+ </div>
185
+ <span class="text-xs font-mono text-emerald-500 bg-emerald-500/10 px-3 py-1 rounded-full border border-emerald-500/20">DYNAMIC</span>
186
+ </div>
187
+
188
+ <form id="provider-form" class="grid md:grid-cols-12 gap-4">
189
+ <div class="md:col-span-3">
190
+ <label class="block text-xs font-mono text-slate-400 mb-2 uppercase tracking-wider">Provider Name *</label>
191
+ <input id="prov-name" type="text" placeholder="e.g. MyProxy"
192
+ class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-3 text-sm font-mono focus:outline-none focus:border-amber-500/50">
193
+ </div>
194
+ <div class="md:col-span-5">
195
+ <label class="block text-xs font-mono text-slate-400 mb-2 uppercase tracking-wider">Base URL *</label>
196
+ <input id="prov-url" type="text" placeholder="https://api.example.com/v1"
197
+ class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-3 text-sm font-mono focus:outline-none focus:border-amber-500/50">
198
+ </div>
199
+ <div class="md:col-span-4">
200
+ <label class="block text-xs font-mono text-slate-400 mb-2 uppercase tracking-wider">API Keys (one per line)</label>
201
+ <textarea id="prov-keys" rows="2" placeholder="sk-abc123...&#10;sk-def456..."
202
+ class="w-full bg-black/30 border border-white/10 rounded-lg px-4 py-3 text-xs font-mono focus:outline-none focus:border-amber-500/50 resize-none"></textarea>
203
+ </div>
204
+ <div class="md:col-span-12 flex gap-3 items-center">
205
+ <button type="button" id="prov-preview-btn"
206
+ class="bg-slate-700 hover:bg-slate-600 text-slate-100 font-medium px-5 py-2.5 rounded-lg transition text-sm">
207
+ Preview Models
208
+ </button>
209
+ <button type="button" id="prov-import-btn"
210
+ class="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-black font-semibold px-5 py-2.5 rounded-lg transition text-sm">
211
+ Import Provider
212
+ </button>
213
+ <span id="prov-status" class="text-xs font-mono text-slate-500"></span>
214
+ </div>
215
+ </form>
216
+
217
+ <!-- Preview panel -->
218
+ <div id="prov-preview" class="hidden mt-6 p-5 bg-black/30 rounded-xl border border-white/5">
219
+ <div class="flex items-center justify-between mb-3">
220
+ <h4 class="font-semibold text-sm">Available Models</h4>
221
+ <span id="prov-preview-count" class="text-xs font-mono text-amber-400">β€”</span>
222
+ </div>
223
+ <div id="prov-preview-list" class="flex flex-wrap gap-2 max-h-48 overflow-y-auto"></div>
224
+ </div>
225
+
226
+ <!-- Providers list -->
227
+ <div class="mt-8">
228
+ <h4 class="text-sm font-mono text-slate-400 uppercase tracking-wider mb-3">Active Providers</h4>
229
+ <div id="providers-list" class="space-y-2">
230
+ <p class="text-xs text-slate-600 font-mono">No dynamic providers yet</p>
231
+ </div>
232
+ </div>
233
  </section>
234
 
235
  <!-- Key Generator -->
 
351
  if (masterKey) {
352
  showDashboard();
353
  loadKeys();
354
+ loadProviders();
355
+ loadModels();
356
  } else {
357
  showAuth();
358
  }
 
371
  authError.classList.add('hidden');
372
  showDashboard();
373
  loadKeys();
374
+ loadProviders();
375
  } else {
376
  authError.textContent = 'Invalid master key';
377
  authError.classList.remove('hidden');
 
412
  if (!r || !r.ok) return;
413
  const data = await r.json();
414
  keysData = data.keys || [];
 
415
  renderTable();
416
+ await loadStats();
417
  renderCharts();
418
  $('last-sync').textContent = new Date().toLocaleTimeString();
419
  } catch (e) {
 
421
  }
422
  }
423
 
424
+ let _statsCache = null;
425
+ async function loadStats() {
426
+ try {
427
+ const r = await api('/admin/stats');
428
+ if (r && r.ok) _statsCache = await r.json();
429
+ } catch (e) { /* ignore */ }
430
+ renderStats();
431
+ }
432
+
433
  function renderStats() {
434
  $('stat-total').textContent = keysData.length;
435
  const active = keysData.filter(k => k.status === 'active');
436
  $('stat-active').textContent = new Set(active.map(k => k.ip)).size;
437
+ if (_statsCache) {
438
+ $('stat-requests').textContent = (_statsCache.total_requests || 0).toLocaleString();
439
+ $('stat-ratelimited').textContent = (_statsCache.rate_limited || 0).toLocaleString();
440
+ }
441
  }
442
 
443
  function maskKey(k) {
 
565
  return d.toLocaleDateString(undefined, { weekday: 'short' });
566
  });
567
 
568
+ const reqData = (_statsCache && _statsCache.requests_by_day) ? _statsCache.requests_by_day : [0,0,0,0,0,0,0];
569
+
570
  if (chartRequests) chartRequests.destroy();
571
  chartRequests = new Chart($('chart-requests'), {
572
  type: 'line',
573
  data: {
574
  labels: days,
575
  datasets: [{
576
+ label: 'Requests', data: reqData,
577
  borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.1)',
578
  fill: true, tension: 0.4, pointRadius: 4, pointBackgroundColor: '#f59e0b',
579
  }]
 
581
  options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { grid: { display: false } } } }
582
  });
583
 
584
+ const modelPalette = ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6', '#64748b'];
585
+ const modelLabels = (_statsCache && _statsCache.models) ? _statsCache.models.map(m => m.name) : [];
586
+ const modelData = (_statsCache && _statsCache.models) ? _statsCache.models.map(m => m.count) : [];
587
+
588
  if (chartModels) chartModels.destroy();
589
+ if (modelLabels.length > 0) {
590
+ chartModels = new Chart($('chart-models'), {
591
+ type: 'doughnut',
592
+ data: {
593
+ labels: modelLabels,
594
+ datasets: [{
595
+ data: modelData,
596
+ backgroundColor: modelPalette.slice(0, modelLabels.length),
597
+ borderColor: '#0a0a0f', borderWidth: 2,
598
+ }]
599
+ },
600
+ options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { family: "'JetBrains Mono'", size: 11 }, padding: 12 } } } }
601
+ });
602
+ }
603
+ }
604
+
605
+ // ── Provider Management ─────────────────────────────────────
606
+ function escapeHtml(s) {
607
+ return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
608
+ }
609
+
610
+ async function loadProviders() {
611
+ try {
612
+ const r = await api('/admin/providers');
613
+ if (!r || !r.ok) return;
614
+ const data = await r.json();
615
+ renderProvidersList(data.providers || []);
616
+ } catch (e) {
617
+ console.error('Error loading providers:', e);
618
+ }
619
+ }
620
+
621
+ function renderProvidersList(providers) {
622
+ const list = $('providers-list');
623
+ if (!list) return;
624
+ if (providers.length === 0) {
625
+ list.innerHTML = '<p class="text-xs text-slate-600 font-mono">No dynamic providers yet</p>';
626
+ return;
627
+ }
628
+ list.innerHTML = providers.map(p => `
629
+ <div class="bg-black/30 rounded-lg p-4 flex items-center justify-between border border-white/5">
630
+ <div class="flex-1 min-w-0">
631
+ <div class="flex items-center gap-2">
632
+ <span class="font-semibold text-amber-400 text-sm">${escapeHtml(p.name)}</span>
633
+ <span class="text-xs font-mono text-slate-600">${p.id}</span>
634
+ </div>
635
+ <div class="text-xs text-slate-400 font-mono mt-1 truncate">${escapeHtml(p.base_url)}</div>
636
+ <div class="flex gap-3 mt-2">
637
+ <span class="text-xs text-slate-500 font-mono">${p.key_count} keys</span>
638
+ <span class="text-xs text-slate-500 font-mono">Β·</span>
639
+ <span class="text-xs text-emerald-400 font-mono">${(p.models || []).length} models</span>
640
+ </div>
641
+ </div>
642
+ <button onclick="window.__deleteProvider('${p.id}')" class="ml-3 text-red-400/70 hover:text-red-400 text-xs font-mono px-2 py-1">DELETE</button>
643
+ </div>
644
+ `).join('');
645
+ }
646
+ window.__deleteProvider = async function(id) {
647
+ if (!confirm('Delete this provider and all its model routes?')) return;
648
+ try {
649
+ const r = await api('/admin/providers/' + id, { method: 'DELETE' });
650
+ if (!r || !r.ok) throw new Error('Delete failed');
651
+ toast('Provider deleted', 'success');
652
+ await loadProviders();
653
+ } catch (e) {
654
+ toast('Delete failed: ' + e.message, 'error');
655
+ }
656
+ };
657
+
658
+ async function previewProvider() {
659
+ const name = $('prov-name').value.trim();
660
+ const baseUrl = $('prov-url').value.trim();
661
+ const keysText = $('prov-keys').value.trim();
662
+ const keys = keysText.split('\n').map(k => k.trim()).filter(k => k);
663
+ const status = $('prov-status');
664
+
665
+ if (!name || !baseUrl) { toast('Name and Base URL required', 'error'); return; }
666
+
667
+ const btn = $('prov-preview-btn');
668
+ btn.disabled = true; btn.textContent = 'Probing...';
669
+ status.textContent = '';
670
+ try {
671
+ const r = await api('/admin/providers/preview', {
672
+ method: 'POST',
673
+ body: JSON.stringify({ name, base_url: baseUrl, keys })
674
+ });
675
+ if (!r || !r.ok) {
676
+ const d = await r.json();
677
+ throw new Error(d?.error?.message || 'Preview failed');
678
+ }
679
+ const data = await r.json();
680
+ const preview = $('prov-preview');
681
+ preview.classList.remove('hidden');
682
+ $('prov-preview-count').textContent = `${data.models.length} models`;
683
+ $('prov-preview-list').innerHTML = data.models.map(m =>
684
+ `<span class="bg-slate-800 border border-white/5 text-slate-300 text-xs font-mono px-2 py-1 rounded">${escapeHtml(m)}</span>`
685
+ ).join('');
686
+ status.textContent = `βœ“ ${data.models.length} models found`;
687
+ status.className = 'text-xs font-mono text-emerald-400';
688
+ } catch (e) {
689
+ status.textContent = `βœ— ${e.message}`;
690
+ status.className = 'text-xs font-mono text-red-400';
691
+ } finally {
692
+ btn.disabled = false; btn.textContent = 'Preview Models';
693
+ }
694
+ }
695
+
696
+ async function importProvider() {
697
+ const name = $('prov-name').value.trim();
698
+ const baseUrl = $('prov-url').value.trim();
699
+ const keysText = $('prov-keys').value.trim();
700
+ const keys = keysText.split('\n').map(k => k.trim()).filter(k => k);
701
+ const status = $('prov-status');
702
+
703
+ if (!name || !baseUrl || keys.length === 0) {
704
+ toast('Name, URL, and at least one key required', 'error'); return;
705
+ }
706
+
707
+ const btn = $('prov-import-btn');
708
+ btn.disabled = true; btn.textContent = 'Importing...';
709
+ status.textContent = '';
710
+ try {
711
+ const r = await api('/admin/providers/import', {
712
+ method: 'POST',
713
+ body: JSON.stringify({ name, base_url: baseUrl, keys })
714
+ });
715
+ if (!r || !r.ok) {
716
+ const d = await r.json();
717
+ throw new Error(d?.error?.message || 'Import failed');
718
+ }
719
+ const data = await r.json();
720
+ toast(`Imported ${data.model_count} models from ${data.name}`, 'success');
721
+ $('prov-name').value = '';
722
+ $('prov-url').value = '';
723
+ $('prov-keys').value = '';
724
+ $('prov-preview').classList.add('hidden');
725
+ status.textContent = `βœ“ ${data.model_count} models registered`;
726
+ status.className = 'text-xs font-mono text-emerald-400';
727
+ await loadProviders();
728
+ } catch (e) {
729
+ status.textContent = `βœ— ${e.message}`;
730
+ status.className = 'text-xs font-mono text-red-400';
731
+ toast('Import failed: ' + e.message, 'error');
732
+ } finally {
733
+ btn.disabled = false; btn.textContent = 'Import Provider';
734
+ }
735
+ }
736
+
737
+ if ($('prov-preview-btn')) $('prov-preview-btn').addEventListener('click', previewProvider);
738
+ if ($('prov-import-btn')) $('prov-import-btn').addEventListener('click', importProvider);
739
+
740
+ // ── Models Catalog ─────────────────────────────────────────
741
+ let _modelsCache = [];
742
+ async function loadModels() {
743
+ try {
744
+ const r = await api('/admin/models');
745
+ if (!r || !r.ok) return;
746
+ const data = await r.json();
747
+ _modelsCache = data.models || [];
748
+ if ($('stat-models')) $('stat-models').textContent = data.total || _modelsCache.length;
749
+ renderModels('');
750
+ const count = $('models-count');
751
+ if (count) count.textContent = `${_modelsCache.length} models`;
752
+ } catch (e) {
753
+ console.error('Error loading models:', e);
754
+ }
755
+ }
756
+ function renderModels(q) {
757
+ const grid = $('models-grid');
758
+ if (!grid) return;
759
+ q = (q || '').toLowerCase();
760
+ const filtered = q ? _modelsCache.filter(m => m.id.toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q)) : _modelsCache;
761
+ if ($('models-count')) $('models-count').textContent = `${filtered.length} models`;
762
+ if (filtered.length === 0) {
763
+ grid.innerHTML = '<p class="text-xs text-slate-600 font-mono col-span-full">No models match</p>';
764
+ return;
765
+ }
766
+ grid.innerHTML = filtered.map(m => `
767
+ <div class="bg-black/30 border border-white/5 hover:border-amber-500/30 rounded-lg p-3 transition cursor-pointer" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}').then(()=>document.dispatchEvent(new CustomEvent('toast',{detail:'Copied: ${escapeHtml(m.id)}'})))">
768
+ <div class="flex items-start justify-between gap-2">
769
+ <span class="font-mono text-xs text-slate-200 break-all">${escapeHtml(m.id)}</span>
770
+ ${m.upstreams > 1 ? `<span class="text-[10px] font-mono text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded flex-shrink-0">x${m.upstreams}</span>` : ''}
771
+ </div>
772
+ <div class="mt-1.5 flex items-center gap-1.5">
773
+ <span class="w-1.5 h-1.5 rounded-full ${m.provider === 'built-in' ? 'bg-blue-400' : m.provider === 'image-backend' ? 'bg-purple-400' : 'bg-emerald-400'}"></span>
774
+ <span class="text-[10px] font-mono text-slate-500 truncate">${escapeHtml(m.provider)}</span>
775
+ </div>
776
+ </div>
777
+ `).join('');
778
  }
779
+ document.addEventListener('toast', (e) => toast(e.detail));
780
+ if ($('models-search')) $('models-search').addEventListener('input', (e) => renderModels(e.target.value));
781
 
782
  initAuth();
783
+ loadModels();
784
  })();
785
  </script>
786
  </body>