ohmyapi commited on
Commit
f1378d8
·
0 Parent(s):

feat: v3.1 — quota checking, dark UI redesign, registration fixes, daily CI

Browse files

- Add HEALTHCHECK to Dockerfile, --timeout-keep-alive 75 to uvicorn
- Redesign admin UI: dark theme, DM Sans + JetBrains Mono, grok2api-style
filter tags (All/Active/Inactive/Exhausted), Test/Delete action buttons
- Add Tavily /usage quota checking during healthcheck, per-key quota endpoint
- Add expires_at field to access tokens with automatic expiry enforcement
- Fix registration: add navigation waits after Auth0 login redirect,
navigate to /api-keys page, save screenshot on failure
- Change CI to daily schedule (5 accounts), upload failure screenshots
- Create hf_space/ directory for minimal HF Space deployment
- Update deploy-hf.yml to use hf_space/ directory

Made-with: Cursor

Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends curl libpq-dev && rm -rf /var/lib/apt/lists/*
4
+
5
+ WORKDIR /app
6
+
7
+ RUN pip install \
8
+ fastapi>=0.104.0 \
9
+ uvicorn>=0.24.0 \
10
+ pydantic>=2.0 \
11
+ requests>=2.31.0 \
12
+ psycopg2-binary>=2.9.0
13
+
14
+ COPY manager/ /app/manager/
15
+ COPY entrypoint.sh /app/
16
+
17
+ RUN mkdir -p /tmp/data && chmod -R 777 /tmp
18
+ RUN chmod +x /app/entrypoint.sh
19
+
20
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
21
+ CMD curl -f http://localhost:7860/health || exit 1
22
+
23
+ EXPOSE 7860
24
+
25
+ CMD ["/app/entrypoint.sh"]
README.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Tavily Key Manager
3
+ emoji: 🔑
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ ```
13
+ ████████╗ █████╗ ██╗ ██╗██╗██╗ ██╗ ██╗
14
+ ╚══██╔══╝██╔══██╗██║ ██║██║██║ ╚██╗ ██╔╝
15
+ ██║ ███████║██║ ██║██║██║ ╚████╔╝
16
+ ██║ ██╔══██║╚██╗ ██╔╝██║██║ ╚██╔╝
17
+ ██║ ██║ ██║ ╚████╔╝ ██║███████╗██║
18
+ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝╚══════╝╚═╝
19
+ ```
20
+
21
+ **Tavily Search Proxy & Key Pool Manager**
22
+
23
+ A managed Tavily API key pool with automatic round-robin selection, health checking, and access token quota system. Drop-in replacement for `api.tavily.com`.
24
+
25
+ ---
26
+
27
+ ## Quick Start
28
+
29
+ Set your base URL to this Space's address:
30
+
31
+ ```bash
32
+ export TAVILY_BASE_URL=https://ohmyapi-tavily.hf.space
33
+ export TAVILY_API_KEY=sk-your-access-token
34
+ ```
35
+
36
+ ### Search
37
+
38
+ ```bash
39
+ curl -X POST https://ohmyapi-tavily.hf.space/v1/search \
40
+ -H "Content-Type: application/json" \
41
+ -H "Authorization: Bearer sk-your-access-token" \
42
+ -d '{"query": "latest AI news", "max_results": 5}'
43
+ ```
44
+
45
+ ### Python
46
+
47
+ ```python
48
+ import requests
49
+
50
+ resp = requests.post(
51
+ "https://ohmyapi-tavily.hf.space/v1/search",
52
+ headers={"Authorization": "Bearer sk-your-access-token"},
53
+ json={"query": "hello world", "max_results": 3}
54
+ )
55
+ print(resp.json())
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Features
61
+
62
+ | Feature | Description |
63
+ |---------|-------------|
64
+ | Key Pool | Automatic round-robin key selection with health checking |
65
+ | Access Tokens | Per-user tokens with monthly quota management |
66
+ | Free Mode | Optional open access without token |
67
+ | Admin Dashboard | Manage keys, tokens, and configuration via web UI |
68
+ | Search Proxy | `/v1/search` and `/v1/extract` compatible with Tavily API |
69
+ | MCP Support | Works with Tavily MCP server for AI agents |
70
+
71
+ ---
72
+
73
+ ## Environment Variables
74
+
75
+ | Variable | Required | Description |
76
+ |----------|----------|-------------|
77
+ | `ADMIN_PASSWORD` | Yes | Dashboard login password |
78
+ | `DATABASE_URL` | Yes | PostgreSQL connection string |
79
+ | `ADMIN_TOKEN` | No | Default admin API token (default: `REDACTED_TOKEN`) |
80
+ | `FREE_MODE` | No | Enable open access (default: `false`) |
81
+
82
+ ---
83
+
84
+ Built with FastAPI + PostgreSQL · Powered by [Tavily](https://tavily.com)
entrypoint.sh ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/bin/sh
2
+ mkdir -p /tmp/data
3
+ exec python -m uvicorn manager.app:app --host 0.0.0.0 --port 7860 --timeout-keep-alive 75
manager/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """manager package"""
manager/app.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application for Tavily token management."""
2
+ import os
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse, HTMLResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from . import db
12
+ from .routes import router, proxy_router
13
+
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
18
+
19
+
20
+ @asynccontextmanager
21
+ async def lifespan(app: FastAPI):
22
+ db.init_db()
23
+ yield
24
+
25
+
26
+ app = FastAPI(title="Tavily Key Manager", version="3.1.0", lifespan=lifespan)
27
+
28
+ app.add_middleware(
29
+ CORSMiddleware,
30
+ allow_origins=["*"],
31
+ allow_methods=["GET", "POST", "DELETE", "PATCH", "OPTIONS"],
32
+ allow_headers=["Authorization", "Content-Type"],
33
+ )
34
+
35
+ app.include_router(router)
36
+ app.include_router(proxy_router)
37
+
38
+
39
+ @app.get("/")
40
+ @app.get("/admin")
41
+ @app.get("/admin/{path:path}")
42
+ def index():
43
+ index_path = os.path.join(STATIC_DIR, "index.html")
44
+ if os.path.exists(index_path):
45
+ return FileResponse(index_path)
46
+ return HTMLResponse("<h1>Tavily Key Manager</h1><p>Dashboard not found.</p>")
47
+
48
+
49
+ @app.get("/health")
50
+ def health():
51
+ stats = db.get_stats()
52
+ return {"status": "ok", **stats}
53
+
54
+
55
+ if os.path.isdir(STATIC_DIR):
56
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
manager/db.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database operations for API key management.
2
+
3
+ Supports PostgreSQL (via DATABASE_URL) with SQLite fallback for local dev.
4
+ """
5
+ import os
6
+ import sqlite3
7
+ import logging
8
+ from contextlib import contextmanager
9
+ from datetime import datetime, timezone
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ DATABASE_URL = os.getenv("DATABASE_URL", "")
14
+ DB_PATH = os.getenv("DB_PATH", "/tmp/data/tavily_keys.db")
15
+
16
+ _use_pg = bool(DATABASE_URL)
17
+
18
+ if _use_pg:
19
+ import psycopg2
20
+ import psycopg2.extras
21
+
22
+
23
+ def _ensure_dir():
24
+ if not _use_pg:
25
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
26
+
27
+
28
+ @contextmanager
29
+ def get_db():
30
+ if _use_pg:
31
+ conn = psycopg2.connect(DATABASE_URL)
32
+ conn.autocommit = False
33
+ try:
34
+ yield conn
35
+ conn.commit()
36
+ except Exception:
37
+ conn.rollback()
38
+ raise
39
+ finally:
40
+ conn.close()
41
+ else:
42
+ _ensure_dir()
43
+ conn = sqlite3.connect(DB_PATH)
44
+ conn.row_factory = sqlite3.Row
45
+ conn.execute("PRAGMA journal_mode=WAL")
46
+ try:
47
+ yield conn
48
+ conn.commit()
49
+ except Exception:
50
+ conn.rollback()
51
+ raise
52
+ finally:
53
+ conn.close()
54
+
55
+
56
+ def _execute(conn, sql, params=None):
57
+ if _use_pg:
58
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
59
+ else:
60
+ cur = conn.cursor()
61
+ cur.execute(sql, params or ())
62
+ return cur
63
+
64
+
65
+ def _fetchall(conn, sql, params=None):
66
+ cur = _execute(conn, sql, params)
67
+ rows = cur.fetchall()
68
+ return [dict(r) for r in rows]
69
+
70
+
71
+ def _fetchone(conn, sql, params=None):
72
+ cur = _execute(conn, sql, params)
73
+ row = cur.fetchone()
74
+ if row is None:
75
+ return None
76
+ return dict(row)
77
+
78
+
79
+ def _sql(template):
80
+ """Convert SQL template: replace ? with %s for PostgreSQL."""
81
+ if _use_pg:
82
+ return template.replace("?", "%s")
83
+ return template
84
+
85
+
86
+ # ── Initialization ──
87
+
88
+ def init_db():
89
+ with get_db() as conn:
90
+ if _use_pg:
91
+ _execute(conn, """
92
+ CREATE TABLE IF NOT EXISTS api_keys (
93
+ id SERIAL PRIMARY KEY,
94
+ email TEXT NOT NULL,
95
+ password TEXT DEFAULT '',
96
+ api_key TEXT NOT NULL UNIQUE,
97
+ status TEXT DEFAULT 'active',
98
+ created_at TEXT NOT NULL,
99
+ last_checked TEXT,
100
+ quota_remaining INTEGER,
101
+ use_count INTEGER DEFAULT 0
102
+ )
103
+ """)
104
+ _execute(conn, """
105
+ CREATE TABLE IF NOT EXISTS meta (
106
+ key TEXT PRIMARY KEY,
107
+ value TEXT
108
+ )
109
+ """)
110
+ _execute(conn, """
111
+ CREATE TABLE IF NOT EXISTS access_tokens (
112
+ id SERIAL PRIMARY KEY,
113
+ token TEXT NOT NULL UNIQUE,
114
+ name TEXT DEFAULT '',
115
+ quota_limit INTEGER DEFAULT 1000,
116
+ quota_used INTEGER DEFAULT 0,
117
+ is_admin BOOLEAN DEFAULT FALSE,
118
+ status TEXT DEFAULT 'active',
119
+ created_at TEXT NOT NULL,
120
+ last_used TEXT,
121
+ expires_at TEXT
122
+ )
123
+ """)
124
+ _execute(conn, """
125
+ CREATE TABLE IF NOT EXISTS config (
126
+ key TEXT PRIMARY KEY,
127
+ value TEXT NOT NULL
128
+ )
129
+ """)
130
+ else:
131
+ _execute(conn, """
132
+ CREATE TABLE IF NOT EXISTS api_keys (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ email TEXT NOT NULL,
135
+ password TEXT DEFAULT '',
136
+ api_key TEXT NOT NULL UNIQUE,
137
+ status TEXT DEFAULT 'active',
138
+ created_at TEXT NOT NULL,
139
+ last_checked TEXT,
140
+ quota_remaining INTEGER,
141
+ use_count INTEGER DEFAULT 0
142
+ )
143
+ """)
144
+ _execute(conn, """
145
+ CREATE TABLE IF NOT EXISTS meta (
146
+ key TEXT PRIMARY KEY,
147
+ value TEXT
148
+ )
149
+ """)
150
+ _execute(conn, """
151
+ CREATE TABLE IF NOT EXISTS access_tokens (
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ token TEXT NOT NULL UNIQUE,
154
+ name TEXT DEFAULT '',
155
+ quota_limit INTEGER DEFAULT 1000,
156
+ quota_used INTEGER DEFAULT 0,
157
+ is_admin INTEGER DEFAULT 0,
158
+ status TEXT DEFAULT 'active',
159
+ created_at TEXT NOT NULL,
160
+ last_used TEXT,
161
+ expires_at TEXT
162
+ )
163
+ """)
164
+ _execute(conn, """
165
+ CREATE TABLE IF NOT EXISTS config (
166
+ key TEXT PRIMARY KEY,
167
+ value TEXT NOT NULL
168
+ )
169
+ """)
170
+
171
+ # Migrations
172
+ for col, default in [("use_count", "0"), ("quota_remaining", "NULL")]:
173
+ try:
174
+ with get_db() as conn:
175
+ if _use_pg:
176
+ _execute(conn, f"ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS {col} INTEGER DEFAULT {default}")
177
+ else:
178
+ _execute(conn, f"ALTER TABLE api_keys ADD COLUMN {col} INTEGER DEFAULT {default}")
179
+ except Exception:
180
+ pass
181
+
182
+ for tbl, col, coltype, default in [("access_tokens", "expires_at", "TEXT", "NULL")]:
183
+ try:
184
+ with get_db() as conn:
185
+ if _use_pg:
186
+ _execute(conn, f"ALTER TABLE {tbl} ADD COLUMN IF NOT EXISTS {col} {coltype} DEFAULT {default}")
187
+ else:
188
+ _execute(conn, f"ALTER TABLE {tbl} ADD COLUMN {col} {coltype} DEFAULT {default}")
189
+ except Exception:
190
+ pass
191
+
192
+ _seed_defaults()
193
+ db_type = "PostgreSQL" if _use_pg else f"SQLite ({DB_PATH})"
194
+ logger.info("Database initialized: %s", db_type)
195
+
196
+
197
+ def _seed_defaults():
198
+ """Seed default config values and admin token if not present."""
199
+ defaults = {
200
+ "admin_password": os.getenv("ADMIN_PASSWORD", ""),
201
+ "admin_token": os.getenv("ADMIN_TOKEN", "REDACTED_TOKEN"),
202
+ "free_mode": os.getenv("FREE_MODE", "false"),
203
+ "default_quota": os.getenv("DEFAULT_QUOTA", "1000"),
204
+ }
205
+ with get_db() as conn:
206
+ for k, v in defaults.items():
207
+ existing = _fetchone(conn, _sql("SELECT value FROM config WHERE key = ?"), (k,))
208
+ if not existing:
209
+ if _use_pg:
210
+ _execute(conn, "INSERT INTO config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO NOTHING", (k, v))
211
+ else:
212
+ _execute(conn, "INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)", (k, v))
213
+
214
+ # Ensure admin token exists
215
+ admin_token = get_config("admin_token")
216
+ if admin_token:
217
+ with get_db() as conn:
218
+ existing = _fetchone(conn, _sql("SELECT id FROM access_tokens WHERE token = ?"), (admin_token,))
219
+ if not existing:
220
+ now = datetime.now(timezone.utc).isoformat()
221
+ if _use_pg:
222
+ _execute(conn,
223
+ "INSERT INTO access_tokens (token, name, quota_limit, quota_used, is_admin, status, created_at) "
224
+ "VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (token) DO NOTHING",
225
+ (admin_token, "Admin", 0, 0, True, "active", now))
226
+ else:
227
+ _execute(conn,
228
+ "INSERT OR IGNORE INTO access_tokens (token, name, quota_limit, quota_used, is_admin, status, created_at) "
229
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
230
+ (admin_token, "Admin", 0, 0, 1, "active", now))
231
+
232
+
233
+ # ── API Key CRUD ──
234
+
235
+ def add_key(email: str, password: str, api_key: str, created_at: str = "") -> int:
236
+ if not created_at:
237
+ created_at = datetime.now(timezone.utc).isoformat()
238
+ with get_db() as conn:
239
+ if _use_pg:
240
+ cur = _execute(conn,
241
+ "INSERT INTO api_keys (email, password, api_key, created_at) VALUES (%s, %s, %s, %s) RETURNING id",
242
+ (email, password, api_key, created_at))
243
+ return cur.fetchone()["id"]
244
+ else:
245
+ cur = _execute(conn,
246
+ "INSERT INTO api_keys (email, password, api_key, created_at) VALUES (?, ?, ?, ?)",
247
+ (email, password, api_key, created_at))
248
+ return cur.lastrowid
249
+
250
+
251
+ def add_keys_batch(keys: list[dict]) -> int:
252
+ added = 0
253
+ with get_db() as conn:
254
+ for k in keys:
255
+ try:
256
+ created = k.get("created_at", datetime.now(timezone.utc).isoformat())
257
+ if _use_pg:
258
+ _execute(conn,
259
+ "INSERT INTO api_keys (email, password, api_key, created_at) "
260
+ "VALUES (%s, %s, %s, %s) ON CONFLICT (api_key) DO NOTHING",
261
+ (k["email"], k.get("password", ""), k["api_key"], created))
262
+ else:
263
+ _execute(conn,
264
+ "INSERT OR IGNORE INTO api_keys (email, password, api_key, created_at) "
265
+ "VALUES (?, ?, ?, ?)",
266
+ (k["email"], k.get("password", ""), k["api_key"], created))
267
+ added += 1
268
+ except Exception:
269
+ pass
270
+ return added
271
+
272
+
273
+ def list_keys(status: str = "") -> list[dict]:
274
+ with get_db() as conn:
275
+ if status:
276
+ return _fetchall(conn, _sql(
277
+ "SELECT * FROM api_keys WHERE status = ? ORDER BY id DESC"), (status,))
278
+ return _fetchall(conn, "SELECT * FROM api_keys ORDER BY id DESC")
279
+
280
+
281
+ def get_key(key_id: int) -> dict | None:
282
+ with get_db() as conn:
283
+ return _fetchone(conn, _sql("SELECT * FROM api_keys WHERE id = ?"), (key_id,))
284
+
285
+
286
+ def delete_key(key_id: int) -> bool:
287
+ with get_db() as conn:
288
+ cur = _execute(conn, _sql("DELETE FROM api_keys WHERE id = ?"), (key_id,))
289
+ return cur.rowcount > 0
290
+
291
+
292
+ def delete_keys_batch(key_ids: list[int]) -> int:
293
+ if not key_ids:
294
+ return 0
295
+ with get_db() as conn:
296
+ if _use_pg:
297
+ cur = _execute(conn, "DELETE FROM api_keys WHERE id = ANY(%s)", (key_ids,))
298
+ else:
299
+ placeholders = ",".join("?" for _ in key_ids)
300
+ cur = _execute(conn, f"DELETE FROM api_keys WHERE id IN ({placeholders})", tuple(key_ids))
301
+ return cur.rowcount
302
+
303
+
304
+ def delete_keys_by_status(statuses: list[str]) -> int:
305
+ if not statuses:
306
+ return 0
307
+ with get_db() as conn:
308
+ if _use_pg:
309
+ placeholders = ",".join("%s" for _ in statuses)
310
+ else:
311
+ placeholders = ",".join("?" for _ in statuses)
312
+ cur = _execute(conn,
313
+ f"DELETE FROM api_keys WHERE status IN ({placeholders})", tuple(statuses))
314
+ return cur.rowcount
315
+
316
+
317
+ def update_status(key_id: int, status: str, quota_remaining: int | None = None):
318
+ now = datetime.now(timezone.utc).isoformat()
319
+ with get_db() as conn:
320
+ if quota_remaining is not None:
321
+ _execute(conn, _sql(
322
+ "UPDATE api_keys SET status = ?, last_checked = ?, quota_remaining = ? WHERE id = ?"
323
+ ), (status, now, quota_remaining, key_id))
324
+ else:
325
+ _execute(conn, _sql(
326
+ "UPDATE api_keys SET status = ?, last_checked = ? WHERE id = ?"
327
+ ), (status, now, key_id))
328
+
329
+
330
+ def update_status_batch(key_ids: list[int], status: str) -> int:
331
+ if not key_ids:
332
+ return 0
333
+ now = datetime.now(timezone.utc).isoformat()
334
+ with get_db() as conn:
335
+ if _use_pg:
336
+ cur = _execute(conn,
337
+ "UPDATE api_keys SET status = %s, last_checked = %s WHERE id = ANY(%s)",
338
+ (status, now, key_ids))
339
+ else:
340
+ placeholders = ",".join("?" for _ in key_ids)
341
+ cur = _execute(conn,
342
+ f"UPDATE api_keys SET status = ?, last_checked = ? WHERE id IN ({placeholders})",
343
+ (status, now, *key_ids))
344
+ return cur.rowcount
345
+
346
+
347
+ def get_next_active_key() -> dict | None:
348
+ """Get the least-recently-checked active key (round-robin) and increment use_count."""
349
+ with get_db() as conn:
350
+ row = _fetchone(conn,
351
+ "SELECT * FROM api_keys WHERE status = 'active' "
352
+ "ORDER BY last_checked ASC NULLS FIRST LIMIT 1")
353
+ if row:
354
+ now = datetime.now(timezone.utc).isoformat()
355
+ _execute(conn, _sql(
356
+ "UPDATE api_keys SET last_checked = ?, use_count = COALESCE(use_count, 0) + 1 WHERE id = ?"
357
+ ), (now, row["id"]))
358
+ return row
359
+ return None
360
+
361
+
362
+ def get_stats() -> dict:
363
+ with get_db() as conn:
364
+ total = _fetchone(conn, "SELECT COUNT(*) as cnt FROM api_keys")["cnt"]
365
+ active = _fetchone(conn, "SELECT COUNT(*) as cnt FROM api_keys WHERE status='active'")["cnt"]
366
+ inactive = _fetchone(conn, "SELECT COUNT(*) as cnt FROM api_keys WHERE status='inactive'")["cnt"]
367
+ exhausted = _fetchone(conn, "SELECT COUNT(*) as cnt FROM api_keys WHERE status='exhausted'")["cnt"]
368
+ total_usage = _fetchone(conn, "SELECT COALESCE(SUM(use_count), 0) as cnt FROM api_keys")["cnt"]
369
+ quota_sum = _fetchone(conn,
370
+ "SELECT COALESCE(SUM(quota_remaining), 0) as cnt FROM api_keys "
371
+ "WHERE status='active' AND quota_remaining IS NOT NULL")["cnt"]
372
+ last_reg = _fetchone(conn, "SELECT created_at FROM api_keys ORDER BY id DESC LIMIT 1")
373
+ last_check = _fetchone(conn, "SELECT value FROM meta WHERE key='last_healthcheck'")
374
+ return {
375
+ "total_keys": total,
376
+ "active_keys": active,
377
+ "inactive_keys": inactive,
378
+ "exhausted_keys": exhausted,
379
+ "total_usage": total_usage,
380
+ "total_quota_remaining": quota_sum if quota_sum else None,
381
+ "last_registration": last_reg["created_at"] if last_reg else None,
382
+ "last_healthcheck": last_check["value"] if last_check else None,
383
+ }
384
+
385
+
386
+ def set_meta(key: str, value: str):
387
+ with get_db() as conn:
388
+ if _use_pg:
389
+ _execute(conn,
390
+ "INSERT INTO meta (key, value) VALUES (%s, %s) "
391
+ "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
392
+ (key, value))
393
+ else:
394
+ _execute(conn, "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value))
395
+
396
+
397
+ def export_all_keys() -> list[dict]:
398
+ with get_db() as conn:
399
+ return _fetchall(conn,
400
+ "SELECT email, password, api_key, status, created_at, last_checked, "
401
+ "COALESCE(use_count, 0) as use_count FROM api_keys ORDER BY id")
402
+
403
+
404
+ # ── Access Token CRUD ──
405
+
406
+ def add_access_token(token: str, name: str = "", quota_limit: int = 1000,
407
+ is_admin: bool = False, expires_at: str | None = None) -> int:
408
+ now = datetime.now(timezone.utc).isoformat()
409
+ with get_db() as conn:
410
+ if _use_pg:
411
+ cur = _execute(conn,
412
+ "INSERT INTO access_tokens (token, name, quota_limit, is_admin, status, created_at, expires_at) "
413
+ "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
414
+ (token, name, quota_limit, is_admin, "active", now, expires_at))
415
+ return cur.fetchone()["id"]
416
+ else:
417
+ cur = _execute(conn,
418
+ "INSERT INTO access_tokens (token, name, quota_limit, is_admin, status, created_at, expires_at) "
419
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
420
+ (token, name, quota_limit, 1 if is_admin else 0, "active", now, expires_at))
421
+ return cur.lastrowid
422
+
423
+
424
+ def list_access_tokens() -> list[dict]:
425
+ with get_db() as conn:
426
+ rows = _fetchall(conn, "SELECT * FROM access_tokens ORDER BY id")
427
+ for r in rows:
428
+ r["is_admin"] = bool(r.get("is_admin"))
429
+ return rows
430
+
431
+
432
+ def get_access_token(token: str) -> dict | None:
433
+ with get_db() as conn:
434
+ row = _fetchone(conn, _sql("SELECT * FROM access_tokens WHERE token = ? AND status = 'active'"), (token,))
435
+ if row:
436
+ row["is_admin"] = bool(row.get("is_admin"))
437
+ exp = row.get("expires_at")
438
+ if exp:
439
+ try:
440
+ if datetime.fromisoformat(exp.replace("Z", "+00:00")) < datetime.now(timezone.utc):
441
+ return None
442
+ except ValueError:
443
+ pass
444
+ return row
445
+
446
+
447
+ def delete_access_token(token_id: int) -> bool:
448
+ with get_db() as conn:
449
+ cur = _execute(conn, _sql("DELETE FROM access_tokens WHERE id = ?"), (token_id,))
450
+ return cur.rowcount > 0
451
+
452
+
453
+ def update_access_token(token_id: int, **kwargs) -> bool:
454
+ allowed = {"name", "quota_limit", "status", "expires_at"}
455
+ updates = {k: v for k, v in kwargs.items() if k in allowed}
456
+ if not updates:
457
+ return False
458
+ with get_db() as conn:
459
+ if _use_pg:
460
+ set_clause = ", ".join(f"{k} = %s" for k in updates)
461
+ _execute(conn, f"UPDATE access_tokens SET {set_clause} WHERE id = %s",
462
+ (*updates.values(), token_id))
463
+ else:
464
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
465
+ _execute(conn, f"UPDATE access_tokens SET {set_clause} WHERE id = ?",
466
+ (*updates.values(), token_id))
467
+ return True
468
+
469
+
470
+ def increment_token_usage(token: str) -> bool:
471
+ now = datetime.now(timezone.utc).isoformat()
472
+ with get_db() as conn:
473
+ cur = _execute(conn, _sql(
474
+ "UPDATE access_tokens SET quota_used = quota_used + 1, last_used = ? WHERE token = ?"),
475
+ (now, token))
476
+ return cur.rowcount > 0
477
+
478
+
479
+ def reset_token_usage(token_id: int) -> bool:
480
+ with get_db() as conn:
481
+ cur = _execute(conn, _sql("UPDATE access_tokens SET quota_used = 0 WHERE id = ?"), (token_id,))
482
+ return cur.rowcount > 0
483
+
484
+
485
+ # ── Config ──
486
+
487
+ def get_config(key: str) -> str | None:
488
+ with get_db() as conn:
489
+ row = _fetchone(conn, _sql("SELECT value FROM config WHERE key = ?"), (key,))
490
+ return row["value"] if row else None
491
+
492
+
493
+ def set_config(key: str, value: str):
494
+ with get_db() as conn:
495
+ if _use_pg:
496
+ _execute(conn,
497
+ "INSERT INTO config (key, value) VALUES (%s, %s) "
498
+ "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
499
+ (key, value))
500
+ else:
501
+ _execute(conn, "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", (key, value))
502
+
503
+
504
+ def get_all_config() -> dict:
505
+ with get_db() as conn:
506
+ rows = _fetchall(conn, "SELECT key, value FROM config ORDER BY key")
507
+ return {r["key"]: r["value"] for r in rows}
manager/models.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for the token manager API."""
2
+ from pydantic import BaseModel
3
+
4
+
5
+ # ── API Key models ──
6
+
7
+ class ApiKeyCreate(BaseModel):
8
+ email: str
9
+ password: str = ""
10
+ api_key: str
11
+ created_at: str = ""
12
+
13
+
14
+ class ApiKeyImport(BaseModel):
15
+ keys: list[ApiKeyCreate]
16
+
17
+
18
+ class ApiKeyResponse(BaseModel):
19
+ id: int
20
+ email: str
21
+ password: str
22
+ api_key: str
23
+ status: str
24
+ created_at: str
25
+ last_checked: str | None = None
26
+ quota_remaining: int | None = None
27
+ use_count: int | None = 0
28
+
29
+
30
+ class ApiKeyListResponse(BaseModel):
31
+ total: int
32
+ active: int
33
+ keys: list[ApiKeyResponse]
34
+
35
+
36
+ class StatsResponse(BaseModel):
37
+ total_keys: int
38
+ active_keys: int
39
+ inactive_keys: int
40
+ exhausted_keys: int
41
+ total_usage: int = 0
42
+ total_quota_remaining: int | None = None
43
+ last_registration: str | None = None
44
+ last_healthcheck: str | None = None
45
+
46
+
47
+ class HealthCheckResult(BaseModel):
48
+ id: int
49
+ api_key: str
50
+ status: str
51
+ message: str
52
+ quota_remaining: int | None = None
53
+
54
+
55
+ class BatchIds(BaseModel):
56
+ ids: list[int]
57
+
58
+
59
+ class BatchStatus(BaseModel):
60
+ ids: list[int]
61
+ status: str
62
+
63
+
64
+ # ── Access Token models ──
65
+
66
+ class AccessTokenCreate(BaseModel):
67
+ name: str = ""
68
+ token: str = ""
69
+ quota_limit: int = 1000
70
+ is_admin: bool = False
71
+ expires_at: str | None = None
72
+
73
+
74
+ class AccessTokenUpdate(BaseModel):
75
+ name: str | None = None
76
+ quota_limit: int | None = None
77
+ status: str | None = None
78
+ expires_at: str | None = None
79
+
80
+
81
+ class AccessTokenResponse(BaseModel):
82
+ id: int
83
+ token: str
84
+ name: str
85
+ quota_limit: int
86
+ quota_used: int
87
+ is_admin: bool
88
+ status: str
89
+ created_at: str
90
+ last_used: str | None = None
91
+ expires_at: str | None = None
92
+
93
+
94
+ # ── Config models ──
95
+
96
+ class ConfigUpdate(BaseModel):
97
+ configs: dict[str, str]
manager/routes.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API routes for the token manager."""
2
+ import os
3
+ import secrets
4
+ import string
5
+ from datetime import datetime, timezone
6
+
7
+ import requests
8
+ from fastapi import APIRouter, Depends, HTTPException, Header, Request
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from . import db
12
+ from .models import (
13
+ ApiKeyCreate, ApiKeyImport, ApiKeyResponse, ApiKeyListResponse,
14
+ StatsResponse, HealthCheckResult, BatchIds, BatchStatus,
15
+ AccessTokenCreate, AccessTokenUpdate, AccessTokenResponse,
16
+ ConfigUpdate,
17
+ )
18
+
19
+ router = APIRouter(prefix="/api")
20
+ proxy_router = APIRouter()
21
+
22
+ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
23
+
24
+
25
+ def verify_auth(authorization: str = Header(None)):
26
+ """Verify admin auth via password or admin token."""
27
+ admin_pw = db.get_config("admin_password") or ADMIN_PASSWORD
28
+ if not admin_pw:
29
+ raise HTTPException(500, "ADMIN_PASSWORD not configured")
30
+ if not authorization:
31
+ raise HTTPException(401, "Authorization required")
32
+ token = authorization.removeprefix("Bearer ").strip()
33
+ if secrets.compare_digest(token, admin_pw):
34
+ return True
35
+ admin_token_val = db.get_config("admin_token") or ""
36
+ if admin_token_val and secrets.compare_digest(token, admin_token_val):
37
+ return True
38
+ raise HTTPException(403, "Invalid credentials")
39
+
40
+
41
+ def _generate_token() -> str:
42
+ chars = string.ascii_letters + string.digits
43
+ return "sk-" + ''.join(secrets.choice(chars) for _ in range(24))
44
+
45
+
46
+ # ── Key Management ──
47
+
48
+ @router.post("/keys", response_model=ApiKeyResponse)
49
+ def create_key(body: ApiKeyCreate, _=Depends(verify_auth)):
50
+ try:
51
+ key_id = db.add_key(body.email, body.password, body.api_key, body.created_at)
52
+ except Exception as e:
53
+ raise HTTPException(409, f"Key already exists: {e}")
54
+ row = db.get_key(key_id)
55
+ return row
56
+
57
+
58
+ @router.post("/keys/import")
59
+ def import_keys(body: ApiKeyImport, _=Depends(verify_auth)):
60
+ keys = [k.model_dump() for k in body.keys]
61
+ added = db.add_keys_batch(keys)
62
+ return {"imported": added, "total": len(keys)}
63
+
64
+
65
+ @router.get("/keys/export")
66
+ def export_keys(_=Depends(verify_auth)):
67
+ keys = db.export_all_keys()
68
+ return JSONResponse(content=keys, headers={
69
+ "Content-Disposition": "attachment; filename=tavily-keys-export.json"
70
+ })
71
+
72
+
73
+ @router.get("/keys", response_model=ApiKeyListResponse)
74
+ def list_keys(status: str = "", _=Depends(verify_auth)):
75
+ keys = db.list_keys(status)
76
+ active = sum(1 for k in keys if k["status"] == "active")
77
+ return {"total": len(keys), "active": active, "keys": keys}
78
+
79
+
80
+ @router.delete("/keys/{key_id}")
81
+ def delete_key(key_id: int, _=Depends(verify_auth)):
82
+ if not db.delete_key(key_id):
83
+ raise HTTPException(404, "Key not found")
84
+ return {"deleted": True}
85
+
86
+
87
+ @router.get("/keys/next")
88
+ def get_next_key(_=Depends(verify_auth)):
89
+ key = db.get_next_active_key()
90
+ if not key:
91
+ raise HTTPException(404, "No active keys available")
92
+ return {"api_key": key["api_key"], "email": key["email"]}
93
+
94
+
95
+ @router.post("/keys/{key_id}/check", response_model=HealthCheckResult)
96
+ def check_single_key(key_id: int, _=Depends(verify_auth)):
97
+ row = db.get_key(key_id)
98
+ if not row:
99
+ raise HTTPException(404, "Key not found")
100
+ status, msg, quota = _check_tavily_key(row["api_key"])
101
+ db.update_status(key_id, status, quota_remaining=quota)
102
+ return {"id": key_id, "api_key": row["api_key"], "status": status, "message": msg, "quota_remaining": quota}
103
+
104
+
105
+ @router.post("/keys/healthcheck")
106
+ def healthcheck_all(_=Depends(verify_auth)):
107
+ keys = db.list_keys()
108
+ results = []
109
+ for row in keys:
110
+ status, msg, quota = _check_tavily_key(row["api_key"])
111
+ db.update_status(row["id"], status, quota_remaining=quota)
112
+ results.append({
113
+ "id": row["id"],
114
+ "api_key": row["api_key"],
115
+ "status": status,
116
+ "message": msg,
117
+ "quota_remaining": quota,
118
+ })
119
+ db.set_meta("last_healthcheck", datetime.now(timezone.utc).isoformat())
120
+ active = sum(1 for r in results if r["status"] == "active")
121
+ return {"checked": len(results), "active": active, "results": results}
122
+
123
+
124
+ @router.get("/keys/{key_id}/quota")
125
+ def get_key_quota(key_id: int, _=Depends(verify_auth)):
126
+ row = db.get_key(key_id)
127
+ if not row:
128
+ raise HTTPException(404, "Key not found")
129
+ quota = _fetch_tavily_quota(row["api_key"])
130
+ if quota is not None:
131
+ db.update_status(key_id, row["status"], quota_remaining=quota)
132
+ return {"id": key_id, "api_key": row["api_key"], "quota_remaining": quota}
133
+
134
+
135
+ @router.get("/stats", response_model=StatsResponse)
136
+ def get_stats(_=Depends(verify_auth)):
137
+ return db.get_stats()
138
+
139
+
140
+ # ── Batch Operations ──
141
+
142
+ @router.post("/keys/batch-delete")
143
+ def batch_delete(body: BatchIds, _=Depends(verify_auth)):
144
+ deleted = db.delete_keys_batch(body.ids)
145
+ return {"deleted": deleted}
146
+
147
+
148
+ @router.post("/keys/batch-status")
149
+ def batch_status(body: BatchStatus, _=Depends(verify_auth)):
150
+ if body.status not in ("active", "inactive", "exhausted"):
151
+ raise HTTPException(400, "Invalid status")
152
+ updated = db.update_status_batch(body.ids, body.status)
153
+ return {"updated": updated}
154
+
155
+
156
+ @router.post("/keys/batch-check")
157
+ def batch_check(body: BatchIds, _=Depends(verify_auth)):
158
+ results = []
159
+ for key_id in body.ids:
160
+ row = db.get_key(key_id)
161
+ if not row:
162
+ continue
163
+ status, msg, quota = _check_tavily_key(row["api_key"])
164
+ db.update_status(key_id, status, quota_remaining=quota)
165
+ results.append({"id": key_id, "api_key": row["api_key"], "status": status, "message": msg, "quota_remaining": quota})
166
+ db.set_meta("last_healthcheck", datetime.now(timezone.utc).isoformat())
167
+ active = sum(1 for r in results if r["status"] == "active")
168
+ return {"checked": len(results), "active": active, "results": results}
169
+
170
+
171
+ @router.delete("/keys/inactive")
172
+ def delete_inactive(_=Depends(verify_auth)):
173
+ deleted = db.delete_keys_by_status(["inactive", "exhausted"])
174
+ return {"deleted": deleted}
175
+
176
+
177
+ # ── Access Token Management ──
178
+
179
+ @router.get("/tokens")
180
+ def list_tokens(_=Depends(verify_auth)):
181
+ return db.list_access_tokens()
182
+
183
+
184
+ @router.post("/tokens")
185
+ def create_token(body: AccessTokenCreate, _=Depends(verify_auth)):
186
+ token_str = body.token or _generate_token()
187
+ default_quota = int(db.get_config("default_quota") or "1000")
188
+ quota = body.quota_limit if body.quota_limit != 1000 else default_quota
189
+ try:
190
+ token_id = db.add_access_token(token_str, body.name, quota, body.is_admin, body.expires_at)
191
+ except Exception as e:
192
+ raise HTTPException(409, f"Token already exists: {e}")
193
+ return {"id": token_id, "token": token_str}
194
+
195
+
196
+ @router.patch("/tokens/{token_id}")
197
+ def update_token(token_id: int, body: AccessTokenUpdate, _=Depends(verify_auth)):
198
+ updates = {k: v for k, v in body.model_dump().items() if v is not None}
199
+ if not updates:
200
+ raise HTTPException(400, "No fields to update")
201
+ db.update_access_token(token_id, **updates)
202
+ return {"updated": True}
203
+
204
+
205
+ @router.delete("/tokens/{token_id}")
206
+ def delete_token(token_id: int, _=Depends(verify_auth)):
207
+ if not db.delete_access_token(token_id):
208
+ raise HTTPException(404, "Token not found")
209
+ return {"deleted": True}
210
+
211
+
212
+ @router.post("/tokens/{token_id}/reset")
213
+ def reset_usage(token_id: int, _=Depends(verify_auth)):
214
+ db.reset_token_usage(token_id)
215
+ return {"reset": True}
216
+
217
+
218
+ # ── Config Management ──
219
+
220
+ @router.get("/config")
221
+ def get_config(_=Depends(verify_auth)):
222
+ return db.get_all_config()
223
+
224
+
225
+ @router.patch("/config")
226
+ def update_config(body: ConfigUpdate, _=Depends(verify_auth)):
227
+ for k, v in body.configs.items():
228
+ db.set_config(k, v)
229
+ return {"updated": list(body.configs.keys())}
230
+
231
+
232
+ # ── Tavily Search Proxy (OpenClaw compatible) ──
233
+
234
+ def _verify_proxy_auth(request: Request) -> dict | None:
235
+ """Verify access token for proxy endpoints. Returns token row or None."""
236
+ free_mode = (db.get_config("free_mode") or "false").lower() == "true"
237
+ auth_header = request.headers.get("authorization", "")
238
+ bearer = auth_header.removeprefix("Bearer ").strip() if auth_header else ""
239
+
240
+ if free_mode:
241
+ if bearer:
242
+ token_row = db.get_access_token(bearer)
243
+ if token_row:
244
+ return token_row
245
+ return {"token": "__free__", "is_admin": False, "quota_limit": 0, "quota_used": 0}
246
+
247
+ if not bearer:
248
+ return None
249
+
250
+ token_row = db.get_access_token(bearer)
251
+ return token_row
252
+
253
+
254
+ @proxy_router.post("/v1/search")
255
+ @proxy_router.post("/search")
256
+ async def proxy_tavily_search(request: Request):
257
+ token_row = _verify_proxy_auth(request)
258
+ if token_row is None:
259
+ return JSONResponse(status_code=401, content={"error": "Valid access token required. Set Authorization: Bearer <token>"})
260
+
261
+ if not token_row.get("is_admin") and token_row.get("quota_limit", 0) > 0:
262
+ if token_row.get("quota_used", 0) >= token_row["quota_limit"]:
263
+ return JSONResponse(status_code=429, content={"error": "Token quota exceeded"})
264
+
265
+ key_row = db.get_next_active_key()
266
+ if not key_row:
267
+ return JSONResponse(status_code=503, content={"error": "No active API keys in pool"})
268
+
269
+ try:
270
+ body = await request.json()
271
+ except Exception:
272
+ return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
273
+
274
+ body["api_key"] = key_row["api_key"]
275
+
276
+ try:
277
+ resp = requests.post("https://api.tavily.com/search", json=body, timeout=30)
278
+ if resp.status_code == 401:
279
+ db.update_status(key_row["id"], "inactive")
280
+ elif resp.status_code == 429:
281
+ db.update_status(key_row["id"], "exhausted")
282
+
283
+ if resp.status_code == 200 and token_row.get("token") != "__free__":
284
+ db.increment_token_usage(token_row["token"])
285
+
286
+ return JSONResponse(
287
+ status_code=resp.status_code,
288
+ content=resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {"raw": resp.text},
289
+ )
290
+ except requests.Timeout:
291
+ return JSONResponse(status_code=504, content={"error": "Tavily API timeout"})
292
+ except Exception as e:
293
+ return JSONResponse(status_code=502, content={"error": f"Proxy error: {e}"})
294
+
295
+
296
+ @proxy_router.post("/v1/extract")
297
+ @proxy_router.post("/extract")
298
+ async def proxy_tavily_extract(request: Request):
299
+ token_row = _verify_proxy_auth(request)
300
+ if token_row is None:
301
+ return JSONResponse(status_code=401, content={"error": "Valid access token required. Set Authorization: Bearer <token>"})
302
+
303
+ if not token_row.get("is_admin") and token_row.get("quota_limit", 0) > 0:
304
+ if token_row.get("quota_used", 0) >= token_row["quota_limit"]:
305
+ return JSONResponse(status_code=429, content={"error": "Token quota exceeded"})
306
+
307
+ key_row = db.get_next_active_key()
308
+ if not key_row:
309
+ return JSONResponse(status_code=503, content={"error": "No active API keys in pool"})
310
+
311
+ try:
312
+ body = await request.json()
313
+ except Exception:
314
+ return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
315
+
316
+ body["api_key"] = key_row["api_key"]
317
+
318
+ try:
319
+ resp = requests.post("https://api.tavily.com/extract", json=body, timeout=30)
320
+ if resp.status_code == 401:
321
+ db.update_status(key_row["id"], "inactive")
322
+ elif resp.status_code == 429:
323
+ db.update_status(key_row["id"], "exhausted")
324
+
325
+ if resp.status_code == 200 and token_row.get("token") != "__free__":
326
+ db.increment_token_usage(token_row["token"])
327
+
328
+ return JSONResponse(
329
+ status_code=resp.status_code,
330
+ content=resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {"raw": resp.text},
331
+ )
332
+ except requests.Timeout:
333
+ return JSONResponse(status_code=504, content={"error": "Tavily API timeout"})
334
+ except Exception as e:
335
+ return JSONResponse(status_code=502, content={"error": f"Proxy error: {e}"})
336
+
337
+
338
+ def _check_tavily_key(api_key: str) -> tuple[str, str, int | None]:
339
+ """Check key validity and return (status, message, quota_remaining)."""
340
+ quota_remaining = None
341
+ try:
342
+ resp = requests.post(
343
+ "https://api.tavily.com/search",
344
+ json={"api_key": api_key, "query": "test", "max_results": 1},
345
+ timeout=15,
346
+ )
347
+ if resp.status_code == 200:
348
+ quota_remaining = _fetch_tavily_quota(api_key)
349
+ return "active", "OK", quota_remaining
350
+ elif resp.status_code == 401:
351
+ return "inactive", "Invalid key", None
352
+ elif resp.status_code == 429:
353
+ return "exhausted", "Rate limited / quota exceeded", 0
354
+ else:
355
+ return "inactive", f"HTTP {resp.status_code}: {resp.text[:100]}", None
356
+ except requests.Timeout:
357
+ return "active", "Timeout (assumed active)", quota_remaining
358
+ except Exception as e:
359
+ return "unknown", f"Check error: {e}", None
360
+
361
+
362
+ def _fetch_tavily_quota(api_key: str) -> int | None:
363
+ """Fetch remaining quota from Tavily /usage endpoint."""
364
+ try:
365
+ resp = requests.get(
366
+ "https://api.tavily.com/usage",
367
+ headers={"Authorization": f"Bearer {api_key}"},
368
+ timeout=10,
369
+ )
370
+ if resp.status_code == 200:
371
+ data = resp.json()
372
+ key_data = data.get("key", {})
373
+ limit = key_data.get("limit")
374
+ usage = key_data.get("usage", 0)
375
+ if limit is not None:
376
+ return max(0, limit - usage)
377
+ except Exception:
378
+ pass
379
+ return None
manager/static/index.html ADDED
@@ -0,0 +1,910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Tavily Key Manager</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+ :root {
12
+ --bg: #0a0a0b; --bg2: #111113; --surface: #18181b; --surface2: #1e1e22;
13
+ --border: #27272a; --border2: #3f3f46;
14
+ --fg: #fafafa; --fg2: #d4d4d8; --fg3: #a1a1aa; --fg4: #71717a; --fg5: #52525b;
15
+ --accent: #3b82f6; --accent2: #60a5fa; --accent-bg: rgba(59,130,246,.12);
16
+ --ok: #22c55e; --ok-bg: rgba(34,197,94,.1); --ok-dim: #16a34a;
17
+ --err: #ef4444; --err-bg: rgba(239,68,68,.1); --err-dim: #dc2626;
18
+ --warn: #f59e0b; --warn-bg: rgba(245,158,11,.1);
19
+ --r: 8px; --rl: 12px;
20
+ --font: 'DM Sans', -apple-system, sans-serif;
21
+ --mono: 'JetBrains Mono', 'SF Mono', monospace;
22
+ }
23
+ html { font-size: 15px; }
24
+ body { background: var(--bg); color: var(--fg); font-family: var(--font); line-height: 1.6; min-height: 100vh; -webkit-font-smoothing: antialiased; }
25
+ ::selection { background: var(--accent); color: #fff; }
26
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
27
+ ::-webkit-scrollbar-track { background: transparent; }
28
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
29
+
30
+ /* ── Login ── */
31
+ .login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; background: radial-gradient(ellipse at 50% 0%, rgba(59,130,246,.06) 0%, transparent 60%); }
32
+ .login-card { width: 100%; max-width: 380px; animation: rise .5s ease both; }
33
+ .login-hdr { text-align: center; margin-bottom: 40px; }
34
+ .login-hdr .logo { width: 56px; height: 56px; background: linear-gradient(135deg, var(--accent), #6366f1); color: #fff; border-radius: 16px; display: inline-flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 700; margin-bottom: 20px; box-shadow: 0 8px 32px rgba(59,130,246,.25); }
35
+ .login-hdr h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -.02em; }
36
+ .login-hdr p { font-size: .875rem; color: var(--fg4); margin-top: 6px; }
37
+ .login-err { margin-top: 14px; padding: 10px 14px; background: var(--err-bg); border: 1px solid rgba(239,68,68,.2); border-radius: var(--r); color: var(--err); font-size: .8rem; text-align: center; }
38
+
39
+ /* ── Inputs ── */
40
+ .inp { width: 100%; height: 44px; padding: 0 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); color: var(--fg); font-family: var(--font); font-size: .9rem; outline: none; transition: border-color .15s, box-shadow .15s; }
41
+ .inp::placeholder { color: var(--fg5); }
42
+ .inp:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
43
+ .inp-mono { font-family: var(--mono); font-size: .8rem; }
44
+ textarea.inp { height: auto; padding: 12px 14px; resize: vertical; font-family: var(--mono); font-size: .8rem; line-height: 1.6; }
45
+
46
+ /* ── Buttons ── */
47
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 36px; padding: 0 16px; border: none; border-radius: var(--r); font-family: var(--font); font-size: .85rem; font-weight: 600; cursor: pointer; transition: all .15s; outline: none; white-space: nowrap; }
48
+ .btn:active { transform: scale(.97); }
49
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
50
+ .btn-p { background: var(--accent); color: #fff; }
51
+ .btn-p:hover { background: #2563eb; }
52
+ .btn-o { background: transparent; color: var(--fg3); border: 1px solid var(--border); }
53
+ .btn-o:hover { border-color: var(--fg4); color: var(--fg); }
54
+ .btn-d { background: var(--err-bg); color: var(--err); border: 1px solid rgba(239,68,68,.15); }
55
+ .btn-d:hover { background: rgba(239,68,68,.18); }
56
+ .btn-lg { height: 44px; padding: 0 24px; font-size: .95rem; }
57
+ .btn-icon { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r); background: transparent; color: var(--fg4); border: none; cursor: pointer; font-size: .9rem; transition: all .12s; }
58
+ .btn-icon:hover { color: var(--fg2); background: var(--surface2); }
59
+ .btn-icon.danger:hover { color: var(--err); background: var(--err-bg); }
60
+ .btn-icon.ok:hover { color: var(--ok); background: var(--ok-bg); }
61
+
62
+ /* ── Navbar ── */
63
+ .nav { display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 24px; background: var(--bg2); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 40; backdrop-filter: blur(12px); }
64
+ .nav-left { display: flex; align-items: center; gap: 24px; height: 100%; }
65
+ .nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: .95rem; }
66
+ .nav-brand .mark { width: 28px; height: 28px; background: linear-gradient(135deg, var(--accent), #6366f1); color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; }
67
+ .nav-tabs { display: flex; gap: 0; height: 100%; }
68
+ .nav-tab { display: flex; align-items: center; padding: 0 16px; font-size: .85rem; font-weight: 500; color: var(--fg4); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
69
+ .nav-tab:hover { color: var(--fg2); }
70
+ .nav-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
71
+ .nav-actions { display: flex; align-items: center; gap: 10px; }
72
+ .nav-meta { font-size: .75rem; color: var(--fg4); padding: 4px 12px; background: var(--surface); border-radius: 999px; border: 1px solid var(--border); font-family: var(--mono); }
73
+
74
+ /* ── Stats ── */
75
+ .stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }
76
+ @media (max-width: 800px) { .stats { grid-template-columns: repeat(2, 1fr); } }
77
+ .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--rl); padding: 18px 20px; transition: border-color .2s; }
78
+ .stat:hover { border-color: var(--border2); }
79
+ .stat-label { font-size: .75rem; color: var(--fg4); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
80
+ .stat-val { font-size: 2rem; font-weight: 700; letter-spacing: -.03em; line-height: 1.1; }
81
+ .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: middle; position: relative; top: -1px; }
82
+
83
+ /* ── Table ── */
84
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--rl); overflow: hidden; }
85
+ .tbl-wrap { overflow-x: auto; }
86
+ table { width: 100%; border-collapse: separate; border-spacing: 0; }
87
+ thead th { padding: 0 16px; height: 42px; font-size: .7rem; font-weight: 600; color: var(--fg4); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; text-transform: uppercase; letter-spacing: .06em; }
88
+ tbody td { padding: 10px 16px; border-bottom: 1px solid rgba(39,39,42,.5); font-size: .85rem; color: var(--fg2); vertical-align: middle; }
89
+ tbody tr { transition: background .1s; }
90
+ tbody tr:hover { background: rgba(255,255,255,.02); }
91
+ tbody tr:last-child td { border-bottom: none; }
92
+ tbody tr.selected { background: var(--accent-bg); }
93
+ .c-id { color: var(--fg5); font-family: var(--mono); font-size: .75rem; }
94
+ .c-key { font-family: var(--mono); font-size: .75rem; color: var(--fg3); background: var(--bg2); padding: 3px 10px; border-radius: 6px; display: inline-block; border: 1px solid var(--border); }
95
+ .c-date { color: var(--fg5); font-size: .75rem; }
96
+ .c-acts { display: flex; gap: 2px; }
97
+
98
+ /* ── Badges ── */
99
+ .badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
100
+ .badge::before { content: ''; width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
101
+ .badge-active { background: var(--ok-bg); color: var(--ok); }
102
+ .badge-active::before { background: var(--ok); }
103
+ .badge-inactive { background: var(--err-bg); color: var(--err); }
104
+ .badge-inactive::before { background: var(--err); }
105
+ .badge-exhausted { background: var(--warn-bg); color: var(--warn); }
106
+ .badge-exhausted::before { background: var(--warn); }
107
+ .badge-unknown { background: rgba(113,113,122,.15); color: var(--fg4); }
108
+ .badge-unknown::before { background: var(--fg5); }
109
+
110
+ .chk { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
111
+
112
+ /* ── Batch bar ── */
113
+ .batch-bar { display: flex; align-items: center; gap: 10px; padding: 10px 18px; background: var(--surface2); border: 1px solid var(--accent); border-radius: var(--r); margin-bottom: 14px; animation: rise .2s ease both; box-shadow: 0 0 20px var(--accent-bg); }
114
+ .batch-bar .batch-count { font-size: .85rem; font-weight: 700; background: var(--accent); color: #fff; padding: 2px 12px; border-radius: 999px; }
115
+ .batch-bar .batch-label { font-size: .85rem; color: var(--fg3); }
116
+
117
+ /* ── Empty ── */
118
+ .empty { text-align: center; padding: 64px 24px; }
119
+ .empty .e-icon { font-size: 2.5rem; margin-bottom: 14px; opacity: .25; }
120
+ .empty p { color: var(--fg4); font-size: .9rem; }
121
+ .empty p+p { color: var(--fg5); margin-top: 4px; font-size: .8rem; }
122
+
123
+ /* ── Modal ── */
124
+ .modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.65); backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 24px; opacity: 0; visibility: hidden; transition: opacity .2s, visibility .2s; }
125
+ .modal-bg.open { opacity: 1; visibility: visible; }
126
+ .modal { width: 100%; max-width: 440px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 24px; box-shadow: 0 24px 64px rgba(0,0,0,.5); transform: scale(.95); transition: transform .2s cubic-bezier(.16,1,.3,1); }
127
+ .modal-bg.open .modal { transform: scale(1); }
128
+ .modal-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 20px; }
129
+ .modal-field { margin-bottom: 16px; }
130
+ .modal-field label { display: block; font-size: .8rem; font-weight: 600; color: var(--fg3); margin-bottom: 6px; }
131
+ .modal-field .hint { color: var(--fg5); font-weight: 400; }
132
+ .modal-acts { display: flex; gap: 10px; margin-top: 24px; justify-content: flex-end; }
133
+
134
+ /* ── Toast ── */
135
+ .toast-c { position: fixed; top: 16px; right: 16px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
136
+ .toast { display: flex; align-items: center; gap: 10px; padding: 12px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); font-size: .85rem; font-weight: 500; color: var(--fg2); max-width: 400px; animation: toastIn .3s cubic-bezier(.16,1,.3,1) both; box-shadow: 0 8px 24px rgba(0,0,0,.3); }
137
+ .toast.out { animation: toastOut .2s ease both; }
138
+ .toast-icon { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; }
139
+ .toast.success .toast-icon { background: var(--ok-bg); color: var(--ok); }
140
+ .toast.error .toast-icon { background: var(--err-bg); color: var(--err); }
141
+ .toast.info .toast-icon { background: var(--accent-bg); color: var(--accent2); }
142
+
143
+ /* ── Filter bar ── */
144
+ .filter-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
145
+ .filter-tags { display: flex; gap: 6px; flex-wrap: wrap; }
146
+ .filter-tag { display: inline-flex; align-items: center; gap: 6px; padding: 5px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; font-family: var(--font); font-size: .8rem; font-weight: 600; color: var(--fg3); cursor: pointer; transition: all .15s; outline: none; }
147
+ .filter-tag:hover { border-color: var(--border2); color: var(--fg2); }
148
+ .filter-tag.active { background: var(--fg); color: var(--bg); border-color: var(--fg); }
149
+ .filter-tag-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
150
+ .filter-tag.active .filter-tag-dot { opacity: .7; }
151
+ .filter-tag-count { font-family: var(--mono); font-size: .75rem; opacity: .8; }
152
+ .agrp { display: flex; gap: 8px; flex-wrap: wrap; }
153
+ .btn-ok { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(34,197,94,.15); }
154
+ .btn-ok:hover { background: rgba(34,197,94,.18); }
155
+
156
+ /* ── Tab content ── */
157
+ .tab-content { display: none; }
158
+ .tab-content.active { display: block; }
159
+
160
+ /* ── Config ── */
161
+ .cfg-section { margin-bottom: 36px; }
162
+ .cfg-section h3 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
163
+ .cfg-section .cfg-desc { font-size: .85rem; color: var(--fg4); margin-bottom: 24px; }
164
+ .cfg-row { margin-bottom: 22px; }
165
+ .cfg-row label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: 3px; }
166
+ .cfg-row .cfg-hint { font-size: .75rem; color: var(--fg5); margin-bottom: 8px; }
167
+ .cfg-row .cfg-input-row { display: flex; gap: 10px; align-items: center; }
168
+ .cfg-row .cfg-input-row .inp { flex: 1; }
169
+ .toggle { position: relative; width: 48px; height: 26px; background: var(--border2); border-radius: 13px; cursor: pointer; transition: background .2s; border: none; }
170
+ .toggle.on { background: var(--accent); }
171
+ .toggle::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: transform .2s; }
172
+ .toggle.on::after { transform: translateX(22px); }
173
+
174
+ /* ── Docs ── */
175
+ .docs { max-width: 740px; }
176
+ .docs h2 { font-size: 1.3rem; font-weight: 700; margin: 36px 0 14px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
177
+ .docs h2:first-child { margin-top: 0; }
178
+ .docs h3 { font-size: 1rem; font-weight: 600; margin: 22px 0 10px; color: var(--fg2); }
179
+ .docs p { margin-bottom: 14px; color: var(--fg3); font-size: .9rem; }
180
+ .docs code { background: var(--surface2); padding: 2px 7px; border-radius: 5px; font-family: var(--mono); font-size: .8rem; border: 1px solid var(--border); color: var(--accent2); }
181
+ .docs pre { background: var(--bg); color: var(--fg2); padding: 18px 22px; border-radius: var(--rl); border: 1px solid var(--border); overflow-x: auto; margin-bottom: 18px; font-family: var(--mono); font-size: .8rem; line-height: 1.7; }
182
+ .docs pre code { background: none; border: none; padding: 0; color: inherit; }
183
+ .docs table { width: 100%; margin-bottom: 18px; }
184
+ .docs table th { background: var(--surface); }
185
+ .docs .info-box { background: var(--accent-bg); border: 1px solid rgba(59,130,246,.2); border-radius: var(--rl); padding: 16px 20px; margin-bottom: 18px; font-size: .85rem; color: var(--fg2); }
186
+ .docs .info-box strong { color: var(--accent2); }
187
+
188
+ /* ── Proxy info ── */
189
+ .proxy-info { background: var(--accent-bg); border: 1px solid rgba(59,130,246,.15); border-radius: var(--rl); padding: 16px 20px; margin-bottom: 20px; font-size: .85rem; }
190
+ .proxy-info strong { color: var(--accent2); }
191
+ .proxy-info span { color: var(--fg3); }
192
+
193
+ /* ── Quota pill ── */
194
+ .quota-pill { font-family: var(--mono); font-size: .7rem; color: var(--fg4); background: var(--surface2); padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border); }
195
+
196
+ /* ── Layout ── */
197
+ .main { max-width: 1160px; margin: 0 auto; padding: 28px 24px 60px; }
198
+ .hidden { display: none !important; }
199
+
200
+ @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
201
+ @keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
202
+ @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(20px); } }
203
+
204
+ @media (max-width: 768px) {
205
+ .nav { padding: 0 12px; }
206
+ .main { padding: 16px 12px 40px; }
207
+ .stats { grid-template-columns: repeat(2, 1fr); }
208
+ .agrp { width: 100%; }
209
+ .agrp .btn { flex: 1; font-size: .75rem; padding: 0 10px; }
210
+ .nav-meta { display: none; }
211
+ .nav-tab { padding: 0 10px; font-size: .8rem; }
212
+ }
213
+ </style>
214
+ </head>
215
+ <body>
216
+ <div class="toast-c" id="toasts"></div>
217
+
218
+ <!-- Login -->
219
+ <div id="login-screen">
220
+ <div class="login-wrap">
221
+ <div class="login-card">
222
+ <div class="login-hdr">
223
+ <div class="logo">T</div>
224
+ <h1>Tavily Key Manager</h1>
225
+ <p>API key pool &amp; search proxy management</p>
226
+ </div>
227
+ <div style="margin-bottom:18px;">
228
+ <input id="pw-in" type="password" class="inp" placeholder="Admin password" onkeydown="if(event.key==='Enter')login()" autofocus>
229
+ </div>
230
+ <button onclick="login()" class="btn btn-p btn-lg" style="width:100%;">Sign in</button>
231
+ <div id="login-err" class="login-err hidden">Invalid credentials</div>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- App -->
237
+ <div id="app" class="hidden">
238
+ <nav class="nav">
239
+ <div class="nav-left">
240
+ <div class="nav-brand"><div class="mark">T</div>Tavily Keys</div>
241
+ <div class="nav-tabs">
242
+ <div class="nav-tab active" data-tab="keys">Keys</div>
243
+ <div class="nav-tab" data-tab="config">Config</div>
244
+ <div class="nav-tab" data-tab="tokens">Tokens</div>
245
+ <div class="nav-tab" data-tab="docs">Docs</div>
246
+ </div>
247
+ </div>
248
+ <div class="nav-actions">
249
+ <span id="nav-count" class="nav-meta"></span>
250
+ <button onclick="refresh()" class="btn-icon" title="Refresh" style="font-size:1.1rem;">&#8635;</button>
251
+ <button onclick="logout()" class="btn btn-o" style="height:30px;font-size:.75rem;padding:0 12px;">Sign out</button>
252
+ </div>
253
+ </nav>
254
+
255
+ <div class="main">
256
+ <!-- === KEYS TAB === -->
257
+ <div id="tab-keys" class="tab-content active">
258
+ <div class="stats" style="animation:rise .4s ease both;">
259
+ <div class="stat"><div class="stat-label">Total</div><div class="stat-val" id="s-total">-</div></div>
260
+ <div class="stat"><div class="stat-label"><span class="dot" style="background:var(--ok);"></span>Active</div><div class="stat-val" id="s-active">-</div></div>
261
+ <div class="stat"><div class="stat-label"><span class="dot" style="background:var(--err);"></span>Inactive</div><div class="stat-val" id="s-inactive">-</div></div>
262
+ <div class="stat"><div class="stat-label"><span class="dot" style="background:var(--warn);"></span>Exhausted</div><div class="stat-val" id="s-exhausted">-</div></div>
263
+ <div class="stat"><div class="stat-label">Pool Quota</div><div class="stat-val" id="s-quota" style="font-size:1.3rem;">-</div></div>
264
+ </div>
265
+
266
+ <div class="proxy-info" style="animation:rise .4s ease .05s both;">
267
+ <strong>Search Proxy</strong> &mdash;
268
+ <span>Set <code style="background:var(--surface2);border:1px solid var(--border);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.75rem;color:var(--accent2);">TAVILY_BASE_URL</code> to <code id="proxy-url" style="cursor:pointer;background:var(--surface2);border:1px solid var(--border);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.75rem;color:var(--fg2);" onclick="copyProxy()" title="Click to copy"></code></span>
269
+ <span id="s-usage" style="color:var(--fg5);margin-left:8px;"></span>
270
+ </div>
271
+
272
+ <div id="batch-bar" class="batch-bar hidden">
273
+ <span style="color:var(--fg3);">Selected</span>
274
+ <span class="batch-count" id="batch-count">0</span>
275
+ <span class="batch-label">items</span>
276
+ <div style="flex:1;"></div>
277
+ <button onclick="batchCheck()" class="btn btn-o" style="height:30px;font-size:.75rem;">Check</button>
278
+ <button onclick="batchDisable()" class="btn btn-o" style="height:30px;font-size:.75rem;">Disable</button>
279
+ <button onclick="batchEnable()" class="btn btn-o" style="height:30px;font-size:.75rem;">Enable</button>
280
+ <button onclick="batchDelete()" class="btn btn-d" style="height:30px;font-size:.75rem;">Delete</button>
281
+ </div>
282
+
283
+ <div class="filter-bar" style="animation:rise .4s ease .1s both;">
284
+ <div class="filter-tags">
285
+ <button class="filter-tag active" data-filter="all" onclick="filterKeys('all',this)"><span class="filter-tag-label">All</span><span class="filter-tag-count" id="ft-all">0</span></button>
286
+ <button class="filter-tag" data-filter="active" onclick="filterKeys('active',this)"><span class="filter-tag-dot" style="background:var(--ok);"></span><span class="filter-tag-label">Active</span><span class="filter-tag-count" id="ft-active">0</span></button>
287
+ <button class="filter-tag" data-filter="inactive" onclick="filterKeys('inactive',this)"><span class="filter-tag-dot" style="background:var(--err);"></span><span class="filter-tag-label">Inactive</span><span class="filter-tag-count" id="ft-inactive">0</span></button>
288
+ <button class="filter-tag" data-filter="exhausted" onclick="filterKeys('exhausted',this)"><span class="filter-tag-dot" style="background:var(--warn);"></span><span class="filter-tag-label">Exhausted</span><span class="filter-tag-count" id="ft-exhausted">0</span></button>
289
+ </div>
290
+ <div class="agrp">
291
+ <button onclick="showModal('add')" class="btn btn-p">Add Key</button>
292
+ <button onclick="showModal('import')" class="btn btn-o">Import</button>
293
+ <button onclick="exportKeys()" class="btn btn-o">Export</button>
294
+ <button onclick="healthcheckAll()" id="btn-hc" class="btn btn-ok">Test</button>
295
+ <button onclick="deleteInactive()" class="btn btn-d">Delete</button>
296
+ </div>
297
+ </div>
298
+
299
+ <div class="card" style="animation:rise .4s ease .15s both;">
300
+ <div class="tbl-wrap">
301
+ <table>
302
+ <thead><tr>
303
+ <th style="width:36px;"><input type="checkbox" class="chk" id="chk-all" onchange="toggleAll(this)"></th>
304
+ <th style="width:44px;">ID</th>
305
+ <th>Email</th>
306
+ <th>API Key</th>
307
+ <th>Status</th>
308
+ <th>Quota</th>
309
+ <th>Created</th>
310
+ <th>Checked</th>
311
+ <th>Uses</th>
312
+ <th style="width:90px;text-align:right;">Actions</th>
313
+ </tr></thead>
314
+ <tbody id="keys-tbl"></tbody>
315
+ </table>
316
+ </div>
317
+ <div id="empty-keys" class="empty hidden">
318
+ <div class="e-icon">&#9711;</div>
319
+ <p>No API keys in the pool</p>
320
+ <p>Add keys manually or run the registration workflow</p>
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- === CONFIG TAB === -->
326
+ <div id="tab-config" class="tab-content">
327
+ <div class="cfg-section">
328
+ <h3>Configuration</h3>
329
+ <p class="cfg-desc">Manage authentication, proxy settings, and system behavior.</p>
330
+
331
+ <div class="cfg-row">
332
+ <label>Admin Token</label>
333
+ <div class="cfg-hint">Default admin access token with unlimited quota.</div>
334
+ <div class="cfg-input-row">
335
+ <input id="cfg-admin-token" type="text" class="inp inp-mono" readonly>
336
+ <button onclick="copyVal('cfg-admin-token')" class="btn-icon" title="Copy">&#128203;</button>
337
+ </div>
338
+ </div>
339
+
340
+ <div class="cfg-row">
341
+ <label>Admin Password</label>
342
+ <div class="cfg-hint">Password for the management dashboard.</div>
343
+ <div class="cfg-input-row">
344
+ <input id="cfg-admin-pw" type="text" class="inp">
345
+ <button onclick="saveConfig('admin_password','cfg-admin-pw')" class="btn btn-p" style="height:44px;">Save</button>
346
+ </div>
347
+ </div>
348
+
349
+ <div class="cfg-row">
350
+ <label>Free Mode</label>
351
+ <div class="cfg-hint">When enabled, proxy accepts any request without a valid token.</div>
352
+ <div style="display:flex;align-items:center;gap:14px;margin-top:10px;">
353
+ <button id="cfg-free-mode" class="toggle" onclick="toggleFreeMode()"></button>
354
+ <span id="cfg-free-label" style="font-size:.85rem;color:var(--fg4);">Off</span>
355
+ </div>
356
+ </div>
357
+
358
+ <div class="cfg-row">
359
+ <label>Default Quota</label>
360
+ <div class="cfg-hint">Default monthly request limit for new access tokens.</div>
361
+ <div class="cfg-input-row">
362
+ <input id="cfg-default-quota" type="number" class="inp" style="max-width:200px;">
363
+ <button onclick="saveConfig('default_quota','cfg-default-quota')" class="btn btn-p" style="height:44px;">Save</button>
364
+ </div>
365
+ </div>
366
+ </div>
367
+ </div>
368
+
369
+ <!-- === TOKENS TAB === -->
370
+ <div id="tab-tokens" class="tab-content">
371
+ <div class="abar" style="margin-top:8px;">
372
+ <div class="abar-title">Access Tokens</div>
373
+ <div class="agrp"><button onclick="showModal('add-token')" class="btn btn-p">Add Token</button></div>
374
+ </div>
375
+ <div class="card">
376
+ <div class="tbl-wrap">
377
+ <table>
378
+ <thead><tr>
379
+ <th style="width:44px;">ID</th>
380
+ <th>Token</th>
381
+ <th>Name</th>
382
+ <th>Type</th>
383
+ <th>Quota</th>
384
+ <th>Used</th>
385
+ <th>Status</th>
386
+ <th>Expires</th>
387
+ <th>Last Used</th>
388
+ <th style="width:90px;text-align:right;">Actions</th>
389
+ </tr></thead>
390
+ <tbody id="tokens-tbl"></tbody>
391
+ </table>
392
+ </div>
393
+ <div id="empty-tokens" class="empty hidden">
394
+ <div class="e-icon">&#9711;</div>
395
+ <p>No access tokens</p>
396
+ <p>Create tokens for users to access the proxy</p>
397
+ </div>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- === DOCS TAB === -->
402
+ <div id="tab-docs" class="tab-content">
403
+ <div class="docs">
404
+ <h2>Tavily Search Proxy API</h2>
405
+ <p>Drop-in replacement for <code>api.tavily.com</code> with managed key pool, round-robin selection, and per-user quota management.</p>
406
+
407
+ <div class="info-box">
408
+ <strong>Base URL:</strong> <code id="doc-base-url"></code>
409
+ </div>
410
+
411
+ <h2>Authentication</h2>
412
+ <p>Include your access token in the <code>Authorization</code> header:</p>
413
+ <pre><code>Authorization: Bearer sk-your-access-token</code></pre>
414
+ <p>If <strong>Free Mode</strong> is enabled, authentication is optional.</p>
415
+
416
+ <h2>Endpoints</h2>
417
+
418
+ <h3>POST /v1/search</h3>
419
+ <p>Web search. Drop-in replacement for <code>api.tavily.com/search</code>.</p>
420
+ <pre><code>curl -X POST BASE_URL/v1/search \
421
+ -H "Content-Type: application/json" \
422
+ -H "Authorization: Bearer sk-your-token" \
423
+ -d '{
424
+ "query": "latest AI news",
425
+ "max_results": 5,
426
+ "search_depth": "basic"
427
+ }'</code></pre>
428
+
429
+ <h3>POST /v1/extract</h3>
430
+ <p>Content extraction. Drop-in replacement for <code>api.tavily.com/extract</code>.</p>
431
+ <pre><code>curl -X POST BASE_URL/v1/extract \
432
+ -H "Content-Type: application/json" \
433
+ -H "Authorization: Bearer sk-your-token" \
434
+ -d '{"urls": ["https://example.com"]}'</code></pre>
435
+
436
+ <h3>GET /health</h3>
437
+ <p>Health check (no auth).</p>
438
+ <pre><code>curl BASE_URL/health</code></pre>
439
+
440
+ <h2>Python</h2>
441
+ <pre><code>import requests
442
+
443
+ resp = requests.post(
444
+ "BASE_URL/v1/search",
445
+ headers={"Authorization": "Bearer sk-your-token"},
446
+ json={"query": "hello world", "max_results": 3}
447
+ )
448
+ print(resp.json())</code></pre>
449
+
450
+ <h2>Environment Variables</h2>
451
+ <pre><code>export TAVILY_BASE_URL=BASE_URL
452
+ export TAVILY_API_KEY=sk-your-access-token</code></pre>
453
+
454
+ <h2>MCP Integration</h2>
455
+ <pre><code>{
456
+ "mcpServers": {
457
+ "tavily": {
458
+ "command": "npx",
459
+ "args": ["-y", "tavily-mcp@latest"],
460
+ "env": {
461
+ "TAVILY_API_KEY": "sk-your-access-token",
462
+ "TAVILY_BASE_URL": "BASE_URL"
463
+ }
464
+ }
465
+ }
466
+ }</code></pre>
467
+
468
+ <h2>Cherry Studio</h2>
469
+ <p>In Cherry Studio settings, configure the Tavily plugin:</p>
470
+ <pre><code>API Base URL: BASE_URL
471
+ API Key: sk-your-access-token</code></pre>
472
+
473
+ <h2>Quotas</h2>
474
+ <table>
475
+ <thead><tr><th>Type</th><th>Quota</th><th>Notes</th></tr></thead>
476
+ <tbody>
477
+ <tr><td>Admin</td><td>Unlimited</td><td>Built-in admin token</td></tr>
478
+ <tr><td>User</td><td>Configurable</td><td>Monthly per-token limit</td></tr>
479
+ <tr><td>Free Mode</td><td>Unlimited</td><td>When free mode is on</td></tr>
480
+ </tbody>
481
+ </table>
482
+ <p>When a token exceeds its quota or passes its expiry date, the API returns <code>429</code> or <code>401</code>.</p>
483
+ </div>
484
+ </div>
485
+ </div>
486
+ </div>
487
+
488
+ <!-- Modals -->
489
+ <div id="modal-add" class="modal-bg" onclick="if(event.target===this)closeModals()">
490
+ <div class="modal">
491
+ <div class="modal-title">Add API Key</div>
492
+ <div class="modal-field"><label>Email</label><input id="add-email" type="text" class="inp" placeholder="user@example.com"></div>
493
+ <div class="modal-field"><label>Password <span class="hint">(optional)</span></label><input id="add-pw" type="text" class="inp" placeholder="Account password"></div>
494
+ <div class="modal-field"><label>API Key</label><input id="add-key" type="text" class="inp inp-mono" placeholder="tvly-..."></div>
495
+ <div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="addKey()" class="btn btn-p">Add</button></div>
496
+ </div>
497
+ </div>
498
+
499
+ <div id="modal-import" class="modal-bg" onclick="if(event.target===this)closeModals()">
500
+ <div class="modal" style="max-width:540px;">
501
+ <div class="modal-title">Batch Import</div>
502
+ <div class="modal-field">
503
+ <label>JSON <span class="hint">(array of {email, password, api_key})</span></label>
504
+ <textarea id="import-json" class="inp" rows="10" placeholder='[{"email":"...","api_key":"tvly-..."}]'></textarea>
505
+ </div>
506
+ <div style="margin-bottom:14px;">
507
+ <label class="btn btn-o" style="cursor:pointer;">Upload JSON<input type="file" accept=".json" style="display:none;" onchange="loadFile(this)"></label>
508
+ </div>
509
+ <div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="importKeys()" class="btn btn-p">Import</button></div>
510
+ </div>
511
+ </div>
512
+
513
+ <div id="modal-add-token" class="modal-bg" onclick="if(event.target===this)closeModals()">
514
+ <div class="modal">
515
+ <div class="modal-title">Create Access Token</div>
516
+ <div class="modal-field"><label>Name <span class="hint">(optional)</span></label><input id="tk-name" type="text" class="inp" placeholder="e.g. User A"></div>
517
+ <div class="modal-field"><label>Token <span class="hint">(blank = auto-generate)</span></label><input id="tk-token" type="text" class="inp inp-mono" placeholder="sk-..."></div>
518
+ <div class="modal-field"><label>Monthly Quota</label><input id="tk-quota" type="number" class="inp" value="1000" style="max-width:200px;"></div>
519
+ <div class="modal-field"><label>Expires <span class="hint">(optional, ISO date)</span></label><input id="tk-expires" type="date" class="inp" style="max-width:220px;"></div>
520
+ <div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="createToken()" class="btn btn-p">Create</button></div>
521
+ </div>
522
+ </div>
523
+
524
+ <script>
525
+ (() => {
526
+ let TOKEN = '';
527
+ const API = '';
528
+ let selectedIds = new Set();
529
+ let allKeys = [];
530
+ let currentFilter = 'all';
531
+
532
+ const $ = id => document.getElementById(id);
533
+ const hdr = () => ({ 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json' });
534
+
535
+ function toast(msg, type = 'info') {
536
+ const el = document.createElement('div');
537
+ el.className = `toast ${type}`;
538
+ const icons = { success: '\u2713', error: '\u2717', info: 'i' };
539
+ el.innerHTML = `<span class="toast-icon">${icons[type]||icons.info}</span><span>${msg}</span>`;
540
+ $('toasts').appendChild(el);
541
+ setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 200); }, 3500);
542
+ }
543
+
544
+ document.querySelectorAll('.nav-tab').forEach(tab => {
545
+ tab.addEventListener('click', () => {
546
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
547
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
548
+ tab.classList.add('active');
549
+ $('tab-' + tab.dataset.tab).classList.add('active');
550
+ if (tab.dataset.tab === 'config') loadConfig();
551
+ if (tab.dataset.tab === 'tokens') loadTokens();
552
+ });
553
+ });
554
+
555
+ window.login = async function() {
556
+ TOKEN = $('pw-in').value.trim();
557
+ if (!TOKEN) return;
558
+ try {
559
+ const r = await fetch(`${API}/api/stats`, { headers: hdr() });
560
+ if (!r.ok) throw 0;
561
+ $('login-screen').classList.add('hidden');
562
+ $('app').classList.remove('hidden');
563
+ localStorage.setItem('tkm_token', TOKEN);
564
+ refresh();
565
+ } catch {
566
+ $('login-err').classList.remove('hidden');
567
+ setTimeout(() => $('login-err').classList.add('hidden'), 3000);
568
+ }
569
+ };
570
+ window.logout = function() {
571
+ TOKEN = '';
572
+ localStorage.removeItem('tkm_token');
573
+ $('app').classList.add('hidden');
574
+ $('login-screen').classList.remove('hidden');
575
+ $('pw-in').value = '';
576
+ };
577
+
578
+ window.refresh = async function() {
579
+ selectedIds.clear();
580
+ updateBatchBar();
581
+ await Promise.all([loadStats(), loadKeys()]);
582
+ };
583
+
584
+ async function loadStats() {
585
+ try {
586
+ const r = await fetch(`${API}/api/stats`, { headers: hdr() });
587
+ const d = await r.json();
588
+ $('s-total').textContent = d.total_keys;
589
+ $('s-active').textContent = d.active_keys;
590
+ $('s-inactive').textContent = d.inactive_keys;
591
+ $('s-exhausted').textContent = d.exhausted_keys;
592
+ $('s-quota').textContent = d.total_quota_remaining != null ? d.total_quota_remaining.toLocaleString() : '\u2014';
593
+ $('nav-count').textContent = `${d.active_keys}/${d.total_keys}`;
594
+ $('s-usage').textContent = `\u00b7 ${(d.total_usage || 0).toLocaleString()} proxy calls`;
595
+ } catch { toast('Failed to load stats', 'error'); }
596
+ }
597
+
598
+ async function loadKeys() {
599
+ try {
600
+ const r = await fetch(`${API}/api/keys`, { headers: hdr() });
601
+ const d = await r.json();
602
+ allKeys = d.keys || [];
603
+ updateFilterCounts();
604
+ renderKeys();
605
+ } catch { toast('Failed to load keys', 'error'); }
606
+ }
607
+ function updateFilterCounts() {
608
+ $('ft-all').textContent = allKeys.length;
609
+ $('ft-active').textContent = allKeys.filter(k => k.status === 'active').length;
610
+ $('ft-inactive').textContent = allKeys.filter(k => k.status === 'inactive').length;
611
+ $('ft-exhausted').textContent = allKeys.filter(k => k.status === 'exhausted').length;
612
+ }
613
+ window.filterKeys = function(filter, el) {
614
+ currentFilter = filter;
615
+ document.querySelectorAll('.filter-tag').forEach(t => t.classList.remove('active'));
616
+ el.classList.add('active');
617
+ selectedIds.clear(); updateBatchBar();
618
+ renderKeys();
619
+ };
620
+ function renderKeys() {
621
+ const filtered = currentFilter === 'all' ? allKeys : allKeys.filter(k => k.status === currentFilter);
622
+ const tbody = $('keys-tbl');
623
+ if (!filtered.length) { tbody.innerHTML = ''; $('empty-keys').classList.remove('hidden'); return; }
624
+ $('empty-keys').classList.add('hidden');
625
+ tbody.innerHTML = filtered.map(k => {
626
+ const ks = k.api_key.length > 20 ? k.api_key.substring(0,10)+'\u2026'+k.api_key.slice(-6) : k.api_key;
627
+ const cr = k.created_at ? new Date(k.created_at).toLocaleDateString('en-CA') : '\u2014';
628
+ const ch = k.last_checked ? new Date(k.last_checked).toLocaleDateString('en-CA') : '\u2014';
629
+ const sel = selectedIds.has(k.id) ? 'checked' : '';
630
+ const qr = k.quota_remaining != null ? k.quota_remaining : '\u2014';
631
+ return `<tr class="${selectedIds.has(k.id)?'selected':''}">
632
+ <td><input type="checkbox" class="chk" data-id="${k.id}" ${sel} onchange="toggleSel(${k.id},this.checked)"></td>
633
+ <td class="c-id">${k.id}</td>
634
+ <td style="color:var(--fg3);font-size:.85rem;">${esc(k.email)}</td>
635
+ <td><span class="c-key">${esc(ks)}</span></td>
636
+ <td><span class="badge badge-${k.status}">${k.status}</span></td>
637
+ <td><span class="quota-pill">${qr}</span></td>
638
+ <td class="c-date">${cr}</td>
639
+ <td class="c-date">${ch}</td>
640
+ <td class="c-id">${k.use_count||0}</td>
641
+ <td><div class="c-acts" style="justify-content:flex-end;">
642
+ <button class="btn-icon" onclick="copyKey('${escA(k.api_key)}')" title="Copy">&#128203;</button>
643
+ <button class="btn-icon ok" onclick="checkKey(${k.id})" title="Test">&#9889;</button>
644
+ <button class="btn-icon danger" onclick="deleteKey(${k.id})" title="Delete">&#128465;</button>
645
+ </div></td></tr>`;
646
+ }).join('');
647
+ }
648
+
649
+ function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
650
+ function escA(s) { return s.replace(/'/g, "\\'").replace(/"/g, '&quot;'); }
651
+
652
+ window.toggleSel = function(id, checked) {
653
+ if (checked) selectedIds.add(id); else selectedIds.delete(id);
654
+ updateBatchBar();
655
+ const row = document.querySelector(`input[data-id="${id}"]`)?.closest('tr');
656
+ if (row) row.classList.toggle('selected', checked);
657
+ };
658
+ window.toggleAll = function(el) {
659
+ document.querySelectorAll('#keys-tbl .chk').forEach(c => {
660
+ c.checked = el.checked;
661
+ const id = parseInt(c.dataset.id);
662
+ if (el.checked) selectedIds.add(id); else selectedIds.delete(id);
663
+ c.closest('tr').classList.toggle('selected', el.checked);
664
+ });
665
+ updateBatchBar();
666
+ };
667
+ function updateBatchBar() {
668
+ const bar = $('batch-bar');
669
+ if (selectedIds.size > 0) { bar.classList.remove('hidden'); $('batch-count').textContent = selectedIds.size; }
670
+ else { bar.classList.add('hidden'); }
671
+ const allChk = $('chk-all');
672
+ if (allChk) allChk.checked = selectedIds.size > 0 && selectedIds.size === document.querySelectorAll('#keys-tbl .chk').length;
673
+ }
674
+
675
+ window.batchDelete = async function() {
676
+ if (!selectedIds.size || !confirm(`Delete ${selectedIds.size} selected keys?`)) return;
677
+ try {
678
+ await fetch(`${API}/api/keys/batch-delete`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds] }) });
679
+ toast(`Deleted ${selectedIds.size} keys`, 'success'); selectedIds.clear(); refresh();
680
+ } catch { toast('Batch delete failed', 'error'); }
681
+ };
682
+ window.batchCheck = async function() {
683
+ if (!selectedIds.size) return;
684
+ toast('Checking selected keys\u2026', 'info');
685
+ try {
686
+ const r = await fetch(`${API}/api/keys/batch-check`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds] }) });
687
+ const d = await r.json();
688
+ toast(`Checked ${d.checked} \u2014 ${d.active} active`, 'success'); refresh();
689
+ } catch { toast('Batch check failed', 'error'); }
690
+ };
691
+ window.batchDisable = async function() {
692
+ if (!selectedIds.size) return;
693
+ try {
694
+ await fetch(`${API}/api/keys/batch-status`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds], status: 'inactive' }) });
695
+ toast(`Disabled ${selectedIds.size} keys`, 'success'); selectedIds.clear(); refresh();
696
+ } catch { toast('Failed', 'error'); }
697
+ };
698
+ window.batchEnable = async function() {
699
+ if (!selectedIds.size) return;
700
+ try {
701
+ await fetch(`${API}/api/keys/batch-status`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds], status: 'active' }) });
702
+ toast(`Enabled ${selectedIds.size} keys`, 'success'); selectedIds.clear(); refresh();
703
+ } catch { toast('Failed', 'error'); }
704
+ };
705
+
706
+ window.showModal = function(name) {
707
+ document.querySelectorAll('.modal-bg').forEach(m => m.classList.remove('open'));
708
+ $(`modal-${name}`).classList.add('open');
709
+ };
710
+ window.closeModals = function() { document.querySelectorAll('.modal-bg').forEach(m => m.classList.remove('open')); };
711
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModals(); });
712
+
713
+ window.addKey = async function() {
714
+ const email = $('add-email').value.trim(), pw = $('add-pw').value.trim(), key = $('add-key').value.trim();
715
+ if (!email || !key) { toast('Email and API key required', 'error'); return; }
716
+ try {
717
+ const r = await fetch(`${API}/api/keys`, { method: 'POST', headers: hdr(), body: JSON.stringify({ email, password: pw, api_key: key }) });
718
+ if (!r.ok) throw new Error(await r.text());
719
+ toast('Key added', 'success'); closeModals(); $('add-email').value=''; $('add-pw').value=''; $('add-key').value=''; refresh();
720
+ } catch (e) { toast(`Failed: ${e.message}`, 'error'); }
721
+ };
722
+ window.importKeys = async function() {
723
+ try {
724
+ let keys = JSON.parse($('import-json').value.trim());
725
+ if (!Array.isArray(keys)) keys = [keys];
726
+ const r = await fetch(`${API}/api/keys/import`, { method: 'POST', headers: hdr(), body: JSON.stringify({ keys }) });
727
+ if (!r.ok) throw new Error(await r.text());
728
+ const d = await r.json();
729
+ toast(`Imported ${d.imported} keys`, 'success'); closeModals(); $('import-json').value=''; refresh();
730
+ } catch (e) { toast(`Import failed: ${e.message}`, 'error'); }
731
+ };
732
+ window.loadFile = function(input) {
733
+ const file = input.files[0]; if (!file) return;
734
+ const reader = new FileReader();
735
+ reader.onload = e => { $('import-json').value = e.target.result; };
736
+ reader.readAsText(file);
737
+ };
738
+ window.deleteKey = async function(id) {
739
+ if (!confirm('Delete this key?')) return;
740
+ try { await fetch(`${API}/api/keys/${id}`, { method: 'DELETE', headers: hdr() }); toast('Deleted', 'success'); refresh(); }
741
+ catch { toast('Failed', 'error'); }
742
+ };
743
+ window.checkKey = async function(id) {
744
+ try {
745
+ const r = await fetch(`${API}/api/keys/${id}/check`, { method: 'POST', headers: hdr() });
746
+ const d = await r.json();
747
+ const msg = d.quota_remaining != null ? `${d.status} \u2014 quota: ${d.quota_remaining}` : `${d.status}: ${d.message}`;
748
+ toast(msg, d.status === 'active' ? 'success' : 'error'); refresh();
749
+ } catch { toast('Check failed', 'error'); }
750
+ };
751
+ window.healthcheckAll = async function() {
752
+ const btn = $('btn-hc'); const orig = btn.textContent;
753
+ btn.textContent = 'Checking\u2026'; btn.disabled = true;
754
+ try {
755
+ const r = await fetch(`${API}/api/keys/healthcheck`, { method: 'POST', headers: hdr() });
756
+ const d = await r.json();
757
+ toast(`Checked ${d.checked} \u2014 ${d.active} active`, 'success'); refresh();
758
+ } catch { toast('Health check failed', 'error'); }
759
+ finally { btn.textContent = orig; btn.disabled = false; }
760
+ };
761
+ window.deleteInactive = async function() {
762
+ if (!confirm('Delete ALL inactive and exhausted keys?')) return;
763
+ try {
764
+ const r = await fetch(`${API}/api/keys/inactive`, { method: 'DELETE', headers: hdr() });
765
+ const d = await r.json();
766
+ toast(`Deleted ${d.deleted} keys`, 'success'); refresh();
767
+ } catch { toast('Failed', 'error'); }
768
+ };
769
+ window.fetchNextKey = async function() {
770
+ try {
771
+ const r = await fetch(`${API}/api/keys/next`, { headers: hdr() });
772
+ if (!r.ok) throw new Error('No active keys');
773
+ const d = await r.json();
774
+ await navigator.clipboard.writeText(d.api_key);
775
+ toast(`Copied: ${d.api_key.substring(0,16)}\u2026`, 'success');
776
+ } catch (e) { toast(e.message, 'error'); }
777
+ };
778
+ window.copyKey = async function(key) {
779
+ try { await navigator.clipboard.writeText(key); toast('Copied', 'success'); } catch { toast('Copy failed', 'error'); }
780
+ };
781
+ window.exportKeys = async function() {
782
+ try {
783
+ const r = await fetch(`${API}/api/keys/export`, { headers: hdr() });
784
+ if (!r.ok) throw new Error('Export failed');
785
+ const data = await r.json();
786
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
787
+ const url = URL.createObjectURL(blob);
788
+ const a = document.createElement('a'); a.href = url; a.download = 'tavily-keys-export.json'; a.click();
789
+ URL.revokeObjectURL(url);
790
+ toast(`Exported ${data.length} keys`, 'success');
791
+ } catch (e) { toast(e.message, 'error'); }
792
+ };
793
+ window.copyProxy = async function() {
794
+ try { await navigator.clipboard.writeText(window.location.origin); toast('URL copied', 'success'); } catch {}
795
+ };
796
+
797
+ async function loadConfig() {
798
+ try {
799
+ const r = await fetch(`${API}/api/config`, { headers: hdr() });
800
+ const cfg = await r.json();
801
+ $('cfg-admin-token').value = cfg.admin_token || '';
802
+ $('cfg-admin-pw').value = cfg.admin_password || '';
803
+ $('cfg-default-quota').value = cfg.default_quota || '1000';
804
+ const fm = (cfg.free_mode || 'false') === 'true';
805
+ $('cfg-free-mode').classList.toggle('on', fm);
806
+ $('cfg-free-label').textContent = fm ? 'On' : 'Off';
807
+ } catch { toast('Failed to load config', 'error'); }
808
+ }
809
+ window.saveConfig = async function(key, inputId) {
810
+ const val = $(inputId).value;
811
+ try {
812
+ await fetch(`${API}/api/config`, { method: 'PATCH', headers: hdr(), body: JSON.stringify({ configs: { [key]: val } }) });
813
+ toast(`Saved`, 'success');
814
+ if (key === 'admin_password' && val) { TOKEN = val; localStorage.setItem('tkm_token', TOKEN); }
815
+ } catch { toast('Save failed', 'error'); }
816
+ };
817
+ window.toggleFreeMode = async function() {
818
+ const btn = $('cfg-free-mode');
819
+ const newVal = btn.classList.contains('on') ? 'false' : 'true';
820
+ try {
821
+ await fetch(`${API}/api/config`, { method: 'PATCH', headers: hdr(), body: JSON.stringify({ configs: { free_mode: newVal } }) });
822
+ btn.classList.toggle('on', newVal === 'true');
823
+ $('cfg-free-label').textContent = newVal === 'true' ? 'On' : 'Off';
824
+ toast(`Free mode ${newVal === 'true' ? 'enabled' : 'disabled'}`, 'success');
825
+ } catch { toast('Failed', 'error'); }
826
+ };
827
+ window.copyVal = async function(id) {
828
+ try { await navigator.clipboard.writeText($(id).value); toast('Copied', 'success'); } catch {}
829
+ };
830
+
831
+ async function loadTokens() {
832
+ try {
833
+ const r = await fetch(`${API}/api/tokens`, { headers: hdr() });
834
+ const tokens = await r.json();
835
+ const tbody = $('tokens-tbl');
836
+ if (!tokens.length) { tbody.innerHTML = ''; $('empty-tokens').classList.remove('hidden'); return; }
837
+ $('empty-tokens').classList.add('hidden');
838
+ const now = new Date();
839
+ tbody.innerHTML = tokens.map(t => {
840
+ const ts = t.token.length > 20 ? t.token.substring(0,10)+'\u2026'+t.token.slice(-6) : t.token;
841
+ const lu = t.last_used ? new Date(t.last_used).toLocaleDateString('en-CA') : '\u2014';
842
+ const type = t.is_admin ? '<span class="badge badge-active">admin</span>' : '<span class="badge badge-exhausted">user</span>';
843
+ let expDisp = '\u2014';
844
+ let expired = false;
845
+ if (t.expires_at) {
846
+ const ed = new Date(t.expires_at);
847
+ expDisp = ed.toLocaleDateString('en-CA');
848
+ if (ed < now) { expired = true; expDisp = `<span style="color:var(--err)">${expDisp}</span>`; }
849
+ }
850
+ return `<tr${expired?' style="opacity:.5"':''}>
851
+ <td class="c-id">${t.id}</td>
852
+ <td><span class="c-key">${esc(ts)}</span></td>
853
+ <td style="color:var(--fg3)">${esc(t.name||'\u2014')}</td>
854
+ <td>${type}</td>
855
+ <td class="c-id">${t.is_admin ? '\u221e' : t.quota_limit}</td>
856
+ <td class="c-id">${t.is_admin ? '\u2014' : t.quota_used}</td>
857
+ <td><span class="badge badge-${t.status}">${t.status}</span></td>
858
+ <td class="c-date">${expDisp}</td>
859
+ <td class="c-date">${lu}</td>
860
+ <td><div class="c-acts" style="justify-content:flex-end;">
861
+ <button class="btn-icon" onclick="copyKey('${escA(t.token)}')" title="Copy">&#128203;</button>
862
+ ${!t.is_admin ? `<button class="btn-icon" onclick="resetTokenUsage(${t.id})" title="Reset">&#8635;</button>` : ''}
863
+ ${!t.is_admin ? `<button class="btn-icon danger" onclick="deleteToken(${t.id})" title="Delete">&#128465;</button>` : ''}
864
+ </div></td></tr>`;
865
+ }).join('');
866
+ } catch { toast('Failed to load tokens', 'error'); }
867
+ }
868
+ window.createToken = async function() {
869
+ const name = $('tk-name').value.trim();
870
+ const token = $('tk-token').value.trim();
871
+ const quota = parseInt($('tk-quota').value) || 1000;
872
+ const expiresRaw = $('tk-expires').value;
873
+ const expires_at = expiresRaw ? new Date(expiresRaw + 'T23:59:59Z').toISOString() : null;
874
+ try {
875
+ const r = await fetch(`${API}/api/tokens`, { method: 'POST', headers: hdr(), body: JSON.stringify({ name, token, quota_limit: quota, expires_at }) });
876
+ if (!r.ok) throw new Error(await r.text());
877
+ const d = await r.json();
878
+ toast(`Created: ${d.token.substring(0,16)}\u2026`, 'success');
879
+ closeModals(); $('tk-name').value=''; $('tk-token').value=''; $('tk-expires').value='';
880
+ loadTokens();
881
+ } catch (e) { toast(`Failed: ${e.message}`, 'error'); }
882
+ };
883
+ window.deleteToken = async function(id) {
884
+ if (!confirm('Delete this token?')) return;
885
+ try { await fetch(`${API}/api/tokens/${id}`, { method: 'DELETE', headers: hdr() }); toast('Deleted', 'success'); loadTokens(); }
886
+ catch { toast('Failed', 'error'); }
887
+ };
888
+ window.resetTokenUsage = async function(id) {
889
+ try { await fetch(`${API}/api/tokens/${id}/reset`, { method: 'POST', headers: hdr() }); toast('Reset', 'success'); loadTokens(); }
890
+ catch { toast('Failed', 'error'); }
891
+ };
892
+
893
+ const baseUrl = window.location.origin;
894
+ $('proxy-url').textContent = baseUrl;
895
+ $('doc-base-url').textContent = baseUrl;
896
+ document.querySelectorAll('#tab-docs pre code').forEach(el => {
897
+ el.textContent = el.textContent.replace(/BASE_URL/g, baseUrl);
898
+ });
899
+
900
+ const saved = localStorage.getItem('tkm_token');
901
+ if (saved) {
902
+ TOKEN = saved;
903
+ fetch(`${API}/api/stats`, { headers: hdr() })
904
+ .then(r => { if (r.ok) { $('login-screen').classList.add('hidden'); $('app').classList.remove('hidden'); refresh(); } })
905
+ .catch(() => {});
906
+ }
907
+ })();
908
+ </script>
909
+ </body>
910
+ </html>