refactor: simplify manager to Tavily-only, harden registrars
Browse files- Remove multi-service (Firecrawl/Exa) UI from manager dashboard, drop edit-key endpoint
- Update README to Tavily-focused docs
- Exa registrar: robust API key extraction with fallback pages and debug logging
- Firecrawl registrar: better error detection, submit button check, debug screenshots
- Feishu notifications: add 3-attempt retry logic
- Disable tavily job in daily workflow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- README.md +35 -23
- manager/db.py +1 -15
- manager/models.py +0 -8
- manager/routes.py +1 -12
- manager/static/index.html +0 -57
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🔑
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
|
@@ -9,39 +9,50 @@ pinned: false
|
|
| 9 |
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
---
|
| 17 |
|
| 18 |
## Quick Start
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
```bash
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
-H "Authorization: Bearer sk-your-token" \
|
| 26 |
-
-d '{"query": "latest AI news", "max_results": 5}'
|
| 27 |
```
|
| 28 |
|
| 29 |
-
###
|
| 30 |
|
| 31 |
```bash
|
| 32 |
-
curl -X POST https://ohmyapi-tavily.hf.space/
|
| 33 |
-H "Content-Type: application/json" \
|
| 34 |
-
-H "Authorization: Bearer sk-your-token" \
|
| 35 |
-
-d '{"
|
| 36 |
```
|
| 37 |
|
| 38 |
-
###
|
| 39 |
|
| 40 |
-
```
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
```
|
| 46 |
|
| 47 |
---
|
|
@@ -50,11 +61,12 @@ curl -X POST https://ohmyapi-tavily.hf.space/exa/search \
|
|
| 50 |
|
| 51 |
| Feature | Description |
|
| 52 |
|---------|-------------|
|
| 53 |
-
|
|
| 54 |
-
| Key Pool | Round-robin key selection with per-service health checking |
|
| 55 |
| Access Tokens | Per-user tokens with monthly quota management |
|
| 56 |
-
| Admin Dashboard | Manage keys, tokens, and configuration via web UI |
|
| 57 |
| Free Mode | Optional open access without token |
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
---
|
| 60 |
|
|
@@ -64,9 +76,9 @@ curl -X POST https://ohmyapi-tavily.hf.space/exa/search \
|
|
| 64 |
|----------|----------|-------------|
|
| 65 |
| `ADMIN_PASSWORD` | Yes | Dashboard login password |
|
| 66 |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
|
| 67 |
-
| `ADMIN_TOKEN` | No | Default admin API token |
|
| 68 |
| `FREE_MODE` | No | Enable open access (default: `false`) |
|
| 69 |
|
| 70 |
---
|
| 71 |
|
| 72 |
-
Built with FastAPI + PostgreSQL
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Tavily Key Manager
|
| 3 |
emoji: 🔑
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
|
|
|
| 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 |
---
|
|
|
|
| 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 |
|
|
|
|
| 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)
|
manager/db.py
CHANGED
|
@@ -204,7 +204,7 @@ def _seed_defaults():
|
|
| 204 |
"""Seed default config values and admin token if not present."""
|
| 205 |
defaults = {
|
| 206 |
"admin_password": os.getenv("ADMIN_PASSWORD", ""),
|
| 207 |
-
"admin_token": os.getenv("ADMIN_TOKEN", "
|
| 208 |
"free_mode": os.getenv("FREE_MODE", "false"),
|
| 209 |
"default_quota": os.getenv("DEFAULT_QUOTA", "1000"),
|
| 210 |
}
|
|
@@ -342,20 +342,6 @@ def update_status(key_id: int, status: str, quota_remaining: int | None = None):
|
|
| 342 |
), (status, now, key_id))
|
| 343 |
|
| 344 |
|
| 345 |
-
def update_key(key_id: int, **kwargs) -> bool:
|
| 346 |
-
"""Update key fields (email, password, api_key, service, status)."""
|
| 347 |
-
allowed = {"email", "password", "api_key", "service", "status"}
|
| 348 |
-
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
| 349 |
-
if not updates:
|
| 350 |
-
return False
|
| 351 |
-
placeholder = "%s" if _use_pg else "?"
|
| 352 |
-
set_clause = ", ".join(f"{k} = {placeholder}" for k in updates)
|
| 353 |
-
sql = f"UPDATE api_keys SET {set_clause} WHERE id = {placeholder}"
|
| 354 |
-
with get_db() as conn:
|
| 355 |
-
cur = _execute(conn, sql, (*updates.values(), key_id))
|
| 356 |
-
return cur.rowcount > 0
|
| 357 |
-
|
| 358 |
-
|
| 359 |
def update_status_batch(key_ids: list[int], status: str) -> int:
|
| 360 |
if not key_ids:
|
| 361 |
return 0
|
|
|
|
| 204 |
"""Seed default config values and admin token if not present."""
|
| 205 |
defaults = {
|
| 206 |
"admin_password": os.getenv("ADMIN_PASSWORD", ""),
|
| 207 |
+
"admin_token": os.getenv("ADMIN_TOKEN", ""),
|
| 208 |
"free_mode": os.getenv("FREE_MODE", "false"),
|
| 209 |
"default_quota": os.getenv("DEFAULT_QUOTA", "1000"),
|
| 210 |
}
|
|
|
|
| 342 |
), (status, now, key_id))
|
| 343 |
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
def update_status_batch(key_ids: list[int], status: str) -> int:
|
| 346 |
if not key_ids:
|
| 347 |
return 0
|
manager/models.py
CHANGED
|
@@ -14,14 +14,6 @@ class ApiKeyCreate(BaseModel):
|
|
| 14 |
created_at: str = ""
|
| 15 |
|
| 16 |
|
| 17 |
-
class ApiKeyUpdate(BaseModel):
|
| 18 |
-
email: str | None = None
|
| 19 |
-
password: str | None = None
|
| 20 |
-
api_key: str | None = None
|
| 21 |
-
service: str | None = None
|
| 22 |
-
status: str | None = None
|
| 23 |
-
|
| 24 |
-
|
| 25 |
class ApiKeyImport(BaseModel):
|
| 26 |
keys: list[ApiKeyCreate]
|
| 27 |
|
|
|
|
| 14 |
created_at: str = ""
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
class ApiKeyImport(BaseModel):
|
| 18 |
keys: list[ApiKeyCreate]
|
| 19 |
|
manager/routes.py
CHANGED
|
@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
|
|
| 12 |
|
| 13 |
from . import db
|
| 14 |
from .models import (
|
| 15 |
-
ApiKeyCreate,
|
| 16 |
StatsResponse, HealthCheckResult, BatchIds, BatchStatus,
|
| 17 |
AccessTokenCreate, AccessTokenUpdate, AccessTokenResponse,
|
| 18 |
ConfigUpdate,
|
|
@@ -215,17 +215,6 @@ def delete_key(key_id: int, _=Depends(verify_auth)):
|
|
| 215 |
return {"deleted": True}
|
| 216 |
|
| 217 |
|
| 218 |
-
@router.patch("/keys/{key_id}")
|
| 219 |
-
def update_key(key_id: int, body: ApiKeyUpdate, _=Depends(verify_auth)):
|
| 220 |
-
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
| 221 |
-
if not updates:
|
| 222 |
-
raise HTTPException(400, "No fields to update")
|
| 223 |
-
if not db.update_key(key_id, **updates):
|
| 224 |
-
raise HTTPException(404, "Key not found")
|
| 225 |
-
row = db.get_key(key_id)
|
| 226 |
-
return row
|
| 227 |
-
|
| 228 |
-
|
| 229 |
@router.get("/keys/next")
|
| 230 |
def get_next_key(service: str = "tavily", _=Depends(verify_auth)):
|
| 231 |
key = db.get_next_active_key(service)
|
|
|
|
| 12 |
|
| 13 |
from . import db
|
| 14 |
from .models import (
|
| 15 |
+
ApiKeyCreate, ApiKeyImport, ApiKeyResponse, ApiKeyListResponse,
|
| 16 |
StatsResponse, HealthCheckResult, BatchIds, BatchStatus,
|
| 17 |
AccessTokenCreate, AccessTokenUpdate, AccessTokenResponse,
|
| 18 |
ConfigUpdate,
|
|
|
|
| 215 |
return {"deleted": True}
|
| 216 |
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
@router.get("/keys/next")
|
| 219 |
def get_next_key(service: str = "tavily", _=Depends(verify_auth)):
|
| 220 |
key = db.get_next_active_key(service)
|
manager/static/index.html
CHANGED
|
@@ -561,31 +561,6 @@ user@example.com,pass,tvly-abc123'></textarea>
|
|
| 561 |
</div>
|
| 562 |
</div>
|
| 563 |
|
| 564 |
-
<div id="modal-edit-key" class="modal-bg" onclick="if(event.target===this)closeModals()">
|
| 565 |
-
<div class="modal">
|
| 566 |
-
<div class="modal-title">Edit Key</div>
|
| 567 |
-
<input id="ek-id" type="hidden">
|
| 568 |
-
<div class="modal-field"><label>Service</label>
|
| 569 |
-
<select id="ek-service" class="inp" style="max-width:200px;">
|
| 570 |
-
<option value="tavily">Tavily</option>
|
| 571 |
-
<option value="firecrawl">Firecrawl</option>
|
| 572 |
-
<option value="exa">Exa</option>
|
| 573 |
-
</select>
|
| 574 |
-
</div>
|
| 575 |
-
<div class="modal-field"><label>Email</label><input id="ek-email" type="text" class="inp"></div>
|
| 576 |
-
<div class="modal-field"><label>Password</label><input id="ek-password" type="text" class="inp"></div>
|
| 577 |
-
<div class="modal-field"><label>API Key</label><input id="ek-apikey" type="text" class="inp inp-mono"></div>
|
| 578 |
-
<div class="modal-field"><label>Status</label>
|
| 579 |
-
<select id="ek-status" class="inp" style="max-width:200px;">
|
| 580 |
-
<option value="active">Active</option>
|
| 581 |
-
<option value="inactive">Inactive</option>
|
| 582 |
-
<option value="exhausted">Exhausted</option>
|
| 583 |
-
</select>
|
| 584 |
-
</div>
|
| 585 |
-
<div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="saveEditKey()" class="btn btn-p">Save</button></div>
|
| 586 |
-
</div>
|
| 587 |
-
</div>
|
| 588 |
-
|
| 589 |
<script>
|
| 590 |
(() => {
|
| 591 |
let TOKEN = '';
|
|
@@ -746,7 +721,6 @@ user@example.com,pass,tvly-abc123'></textarea>
|
|
| 746 |
<td class="c-id">${k.use_count||0}</td>
|
| 747 |
<td><div class="c-acts" style="justify-content:flex-end;">
|
| 748 |
<button class="btn-icon" onclick="copyKey('${escA(k.api_key)}')" title="Copy">📋</button>
|
| 749 |
-
<button class="btn-icon" onclick="editKey(${k.id},'${escA(k.email)}','${escA(k.password)}','${escA(k.api_key)}','${escA(k.service||'tavily')}','${escA(k.status)}')" title="Edit">✎</button>
|
| 750 |
<button class="btn-icon ok" onclick="checkKey(${k.id})" title="Test">⚡</button>
|
| 751 |
<button class="btn-icon danger" onclick="deleteKey(${k.id})" title="Delete">🗑</button>
|
| 752 |
</div></td></tr>`;
|
|
@@ -868,37 +842,6 @@ user@example.com,pass,tvly-abc123'></textarea>
|
|
| 868 |
reader.onload = e => { $('import-json').value = e.target.result; };
|
| 869 |
reader.readAsText(file);
|
| 870 |
};
|
| 871 |
-
window.editKey = function(id, email, password, apiKey, service, status) {
|
| 872 |
-
$('ek-id').value = id;
|
| 873 |
-
$('ek-email').value = email;
|
| 874 |
-
$('ek-password').value = password;
|
| 875 |
-
$('ek-apikey').value = apiKey;
|
| 876 |
-
$('ek-service').value = service;
|
| 877 |
-
$('ek-status').value = status;
|
| 878 |
-
$('modal-edit-key').classList.add('active');
|
| 879 |
-
};
|
| 880 |
-
window.saveEditKey = async function() {
|
| 881 |
-
const id = $('ek-id').value;
|
| 882 |
-
const body = {};
|
| 883 |
-
const email = $('ek-email').value.trim();
|
| 884 |
-
const password = $('ek-password').value.trim();
|
| 885 |
-
const apiKey = $('ek-apikey').value.trim();
|
| 886 |
-
const service = $('ek-service').value;
|
| 887 |
-
const status = $('ek-status').value;
|
| 888 |
-
if (email) body.email = email;
|
| 889 |
-
if (password) body.password = password;
|
| 890 |
-
if (apiKey) body.api_key = apiKey;
|
| 891 |
-
if (service) body.service = service;
|
| 892 |
-
if (status) body.status = status;
|
| 893 |
-
try {
|
| 894 |
-
const r = await fetch(`${API}/api/keys/${id}`, {
|
| 895 |
-
method: 'PATCH', headers: { ...hdr(), 'Content-Type': 'application/json' },
|
| 896 |
-
body: JSON.stringify(body),
|
| 897 |
-
});
|
| 898 |
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 899 |
-
toast('Key updated', 'success'); closeModals(); refresh();
|
| 900 |
-
} catch(e) { toast('Update failed: ' + e.message, 'error'); }
|
| 901 |
-
};
|
| 902 |
window.deleteKey = async function(id) {
|
| 903 |
if (!confirm('Delete this key?')) return;
|
| 904 |
try { await fetch(`${API}/api/keys/${id}`, { method: 'DELETE', headers: hdr() }); toast('Deleted', 'success'); refresh(); }
|
|
|
|
| 561 |
</div>
|
| 562 |
</div>
|
| 563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
<script>
|
| 565 |
(() => {
|
| 566 |
let TOKEN = '';
|
|
|
|
| 721 |
<td class="c-id">${k.use_count||0}</td>
|
| 722 |
<td><div class="c-acts" style="justify-content:flex-end;">
|
| 723 |
<button class="btn-icon" onclick="copyKey('${escA(k.api_key)}')" title="Copy">📋</button>
|
|
|
|
| 724 |
<button class="btn-icon ok" onclick="checkKey(${k.id})" title="Test">⚡</button>
|
| 725 |
<button class="btn-icon danger" onclick="deleteKey(${k.id})" title="Delete">🗑</button>
|
| 726 |
</div></td></tr>`;
|
|
|
|
| 842 |
reader.onload = e => { $('import-json').value = e.target.result; };
|
| 843 |
reader.readAsText(file);
|
| 844 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
window.deleteKey = async function(id) {
|
| 846 |
if (!confirm('Delete this key?')) return;
|
| 847 |
try { await fetch(`${API}/api/keys/${id}`, { method: 'DELETE', headers: hdr() }); toast('Deleted', 'success'); refresh(); }
|