KiWA001 commited on
Commit
0f810f2
·
1 Parent(s): d2b69dd

feat: OpenAI-compatible API with token auth and admin management

Browse files
Files changed (9) hide show
  1. admin_router.py +93 -0
  2. config.py +3 -0
  3. db.py +19 -0
  4. main.py +18 -5
  5. services.py +6 -0
  6. static/admin.html +99 -1
  7. supabase_schema.sql +13 -0
  8. utils.py +37 -0
  9. v1_router.py +212 -0
admin_router.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, BackgroundTasks
2
+ from pydantic import BaseModel
3
+ from typing import List, Optional
4
+ import secrets
5
+ import uuid
6
+
7
+ from db import get_supabase
8
+
9
+ router = APIRouter(prefix="/admin", tags=["Admin"])
10
+
11
+ # --- Models ---
12
+
13
+ class APIKey(BaseModel):
14
+ id: str
15
+ name: str
16
+ token: str
17
+ usage_tokens: int
18
+ limit_tokens: int
19
+ created_at: str
20
+ is_active: bool
21
+
22
+ class CreateKeyRequest(BaseModel):
23
+ name: str
24
+ limit_tokens: Optional[int] = 1000000
25
+
26
+ # --- Endpoints ---
27
+
28
+ @router.get("/keys", response_model=List[APIKey])
29
+ async def list_keys():
30
+ """List all API keys."""
31
+ supabase = get_supabase()
32
+ if not supabase:
33
+ raise HTTPException(status_code=503, detail="Database unavailable")
34
+
35
+ try:
36
+ res = supabase.table("api_keys").select("*").order("created_at", desc=True).execute()
37
+ return res.data
38
+ except Exception as e:
39
+ raise HTTPException(status_code=500, detail=str(e))
40
+
41
+ @router.post("/keys", response_model=APIKey)
42
+ async def create_key(req: CreateKeyRequest):
43
+ """Create a new API key."""
44
+ supabase = get_supabase()
45
+ if not supabase:
46
+ raise HTTPException(status_code=503, detail="Database unavailable")
47
+
48
+ # Generate a secure token
49
+ token = f"sk-kai-{secrets.token_urlsafe(16)}"
50
+
51
+ new_key = {
52
+ "name": req.name,
53
+ "token": token,
54
+ "limit_tokens": req.limit_tokens,
55
+ "usage_tokens": 0,
56
+ "is_active": True
57
+ }
58
+
59
+ try:
60
+ res = supabase.table("api_keys").insert(new_key).execute()
61
+ if res.data:
62
+ return res.data[0]
63
+ raise HTTPException(status_code=500, detail="Failed to create key")
64
+ except Exception as e:
65
+ raise HTTPException(status_code=500, detail=str(e))
66
+
67
+ @router.delete("/keys/{key_id}")
68
+ async def revoke_key(key_id: str):
69
+ """Revoke (delete) an API key."""
70
+ supabase = get_supabase()
71
+ if not supabase:
72
+ raise HTTPException(status_code=503, detail="Database unavailable")
73
+
74
+ try:
75
+ # Check if exists first? Or just delete.
76
+ # Hard delete for now, or soft delete if we had is_active column logic in router update, but delete is cleaner for management
77
+ res = supabase.table("api_keys").delete().eq("id", key_id).execute()
78
+ return {"status": "success", "deleted": key_id}
79
+ except Exception as e:
80
+ raise HTTPException(status_code=500, detail=str(e))
81
+
82
+ @router.post("/keys/{key_id}/reset")
83
+ async def reset_usage(key_id: str):
84
+ """Reset usage for a key."""
85
+ supabase = get_supabase()
86
+ if not supabase:
87
+ raise HTTPException(status_code=503, detail="Database unavailable")
88
+
89
+ try:
90
+ supabase.table("api_keys").update({"usage_tokens": 0}).eq("id", key_id).execute()
91
+ return {"status": "reset"}
92
+ except Exception as e:
93
+ raise HTTPException(status_code=500, detail=str(e))
config.py CHANGED
@@ -63,6 +63,9 @@ POLLINATIONS_MODEL_NAMES = {
63
  "midijourney": "midijourney",
64
  }
65
 
 
 
 
66
  # Models per provider (for /models endpoint)
67
  PROVIDER_MODELS = {
68
  "g4f": [
 
63
  "midijourney": "midijourney",
64
  }
65
 
66
+ # API Keys
67
+ DEMO_API_KEY = "sk-kai-demo-public"
68
+
69
  # Models per provider (for /models endpoint)
70
  PROVIDER_MODELS = {
71
  "g4f": [
db.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from supabase import create_client, Client
3
+ from config import SUPABASE_URL, SUPABASE_KEY
4
+
5
+ logger = logging.getLogger("kai_api.db")
6
+
7
+ try:
8
+ if SUPABASE_URL and SUPABASE_KEY:
9
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
10
+ logger.info("✅ Supabase client initialized")
11
+ else:
12
+ supabase = None
13
+ logger.warning("⚠️ Supabase credentials missing (check config.py)")
14
+ except Exception as e:
15
+ supabase = None
16
+ logger.error(f"❌ Failed to initialize Supabase: {e}")
17
+
18
+ def get_supabase() -> Client:
19
+ return supabase
main.py CHANGED
@@ -38,8 +38,17 @@ from models import (
38
  HealthResponse,
39
  ProviderHealth,
40
  )
41
- from engine import AIEngine
42
- from search_engine import SearchEngine
 
 
 
 
 
 
 
 
 
43
 
44
  # ---------- Logging ----------
45
  logging.basicConfig(
@@ -69,9 +78,13 @@ app.add_middleware(
69
  allow_headers=CORS_HEADERS,
70
  )
71
 
72
- # AI Engine (initialized once, but each request is stateless internally)
73
- engine = AIEngine()
74
- search_engine = SearchEngine()
 
 
 
 
75
 
76
 
77
  # ---------- Admin Routes ----------
 
38
  HealthResponse,
39
  ProviderHealth,
40
  )
41
+ from models import (
42
+ ChatRequest,
43
+ ChatResponse,
44
+ ErrorResponse,
45
+ ModelsResponse,
46
+ HealthResponse,
47
+ ProviderHealth,
48
+ )
49
+ from services import engine, search_engine
50
+ from v1_router import router as v1_router
51
+ from admin_router import router as admin_router
52
 
53
  # ---------- Logging ----------
54
  logging.basicConfig(
 
78
  allow_headers=CORS_HEADERS,
79
  )
80
 
81
+ # AI Engine (initialized via services.py)
82
+ # engine = AIEngine() -> Moved to services.py
83
+ # search_engine = SearchEngine() -> Moved to services.py
84
+
85
+ # Include OpenAI Router
86
+ app.include_router(v1_router)
87
+ app.include_router(admin_router)
88
 
89
 
90
  # ---------- Admin Routes ----------
services.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from engine import AIEngine
2
+ from search_engine import SearchEngine
3
+
4
+ # Singleton instances to be shared across modules
5
+ engine = AIEngine()
6
+ search_engine = SearchEngine()
static/admin.html CHANGED
@@ -206,7 +206,33 @@
206
  </div>
207
  </div>
208
 
209
- <!-- Removed Charts & Tables (Moved to Main Page) -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  <div id="error-console"
212
  style="display:none; background:#ef4444; color:white; padding:10px; margin-bottom:20px; border-radius:8px; font-family:monospace;">
@@ -280,6 +306,78 @@
280
  btn.disabled = false;
281
  }
282
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  </script>
284
  </body>
285
 
 
206
  </div>
207
  </div>
208
 
209
+ <!-- API Key Management Section -->
210
+ <div class="card" style="margin-bottom: 30px;">
211
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
212
+ <h2>API Key Management</h2>
213
+ <button onclick="createKey()"
214
+ style="background: var(--success); color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer;">
215
+ + Create New Key
216
+ </button>
217
+ </div>
218
+
219
+ <table id="keys-table">
220
+ <thead>
221
+ <tr>
222
+ <th>Name</th>
223
+ <th>Token Prefix</th>
224
+ <th>Usage / Limit</th>
225
+ <th>Created</th>
226
+ <th>Action</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody id="keys-list">
230
+ <tr>
231
+ <td colspan="5" style="text-align:center; padding:20px;">Loading keys...</td>
232
+ </tr>
233
+ </tbody>
234
+ </table>
235
+ </div>
236
 
237
  <div id="error-console"
238
  style="display:none; background:#ef4444; color:white; padding:10px; margin-bottom:20px; border-radius:8px; font-family:monospace;">
 
306
  btn.disabled = false;
307
  }
308
  }
309
+ async function loadKeys() {
310
+ try {
311
+ const res = await fetch('/admin/keys');
312
+ const keys = await res.json();
313
+
314
+ const tbody = document.getElementById('keys-list');
315
+ tbody.innerHTML = '';
316
+
317
+ keys.forEach(key => {
318
+ const usagePercent = key.limit_tokens > 0 ? Math.round((key.usage_tokens / key.limit_tokens) * 100) : 0;
319
+ const color = usagePercent > 90 ? 'red' : (usagePercent > 50 ? 'orange' : '#22c55e');
320
+
321
+ const row = `
322
+ <tr>
323
+ <td style="font-weight:bold; color:white;">${key.name}</td>
324
+ <td style="font-family:monospace; color:var(--text-muted);">${key.token.substring(0, 10)}...</td>
325
+ <td>
326
+ <div style="display:flex; align-items:center; gap:10px;">
327
+ <span style="min-width:100px;">${key.usage_tokens.toLocaleString()} / ${key.limit_tokens.toLocaleString()}</span>
328
+ <div style="width:100px; height:6px; background:#333; border-radius:3px; overflow:hidden;">
329
+ <div style="height:100%; width:${Math.min(usagePercent, 100)}%; background:${color};"></div>
330
+ </div>
331
+ </div>
332
+ </td>
333
+ <td>${new Date(key.created_at).toLocaleDateString()}</td>
334
+ <td>
335
+ <button onclick="revokeKey('${key.id}')" style="background:var(--error); color:white; border:none; padding:4px 8px; border-radius:4px; cursor:pointer;">Revoke</button>
336
+ </td>
337
+ </tr>
338
+ `;
339
+ tbody.innerHTML += row;
340
+ });
341
+ } catch (e) {
342
+ console.error("Failed to load keys", e);
343
+ }
344
+ }
345
+
346
+ async function createKey() {
347
+ const name = prompt("Enter Name for new API Key (e.g. 'John Doe'):");
348
+ if (!name) return;
349
+
350
+ try {
351
+ const res = await fetch('/admin/keys', {
352
+ method: 'POST',
353
+ headers: { 'Content-Type': 'application/json' },
354
+ body: JSON.stringify({ name: name, limit_tokens: 1000000 })
355
+ });
356
+
357
+ if (res.ok) {
358
+ const key = await res.json();
359
+ alert(`✅ Key Created!\n\nToken: ${key.token}\n\nSAVE THIS NOW. IT IS SHOWN ONLY ONCE.`);
360
+ loadKeys();
361
+ } else {
362
+ alert("Failed to create key");
363
+ }
364
+ } catch (e) {
365
+ alert("Error: " + e.message);
366
+ }
367
+ }
368
+
369
+ async function revokeKey(id) {
370
+ if (!confirm("Are you sure you want to delete this key? Access will be immediately revoked.")) return;
371
+ try {
372
+ await fetch(`/admin/keys/${id}`, { method: 'DELETE' });
373
+ loadKeys();
374
+ } catch (e) {
375
+ alert("Error: " + e.message);
376
+ }
377
+ }
378
+
379
+ // Initial Load
380
+ loadKeys();
381
  </script>
382
  </body>
383
 
supabase_schema.sql ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Create API Keys Table
2
+ create table public.api_keys (
3
+ id uuid default gen_random_uuid() primary key,
4
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
5
+ name text not null,
6
+ token text not null unique,
7
+ usage_tokens bigint default 0,
8
+ limit_tokens bigint default 1000000, -- Default 1M tokens
9
+ is_active boolean default true
10
+ );
11
+
12
+ -- Indexes for performance
13
+ create index idx_api_keys_token on public.api_keys(token);
utils.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ logger = logging.getLogger("kai_api.utils")
4
+
5
+ def estimate_tokens(text: str) -> int:
6
+ """
7
+ Estimate token count using a simple rule of thumb:
8
+ 1 word = ~1.33 tokens (English).
9
+ Or roughly 4 chars = 1 token.
10
+
11
+ We'll use (len(text) / 4) as a fast approximation.
12
+ Minimum 1 token if text exists.
13
+ """
14
+ if not text:
15
+ return 0
16
+
17
+ count = int(len(text) / 4)
18
+ return max(1, count)
19
+
20
+ def calculate_usage(messages: list[dict], response_text: str) -> dict:
21
+ """
22
+ Calculate prompt_tokens and completion_tokens.
23
+ """
24
+ prompt_text = ""
25
+ for msg in messages:
26
+ content = msg.get("content", "")
27
+ if isinstance(content, str):
28
+ prompt_text += content + "\n"
29
+
30
+ prompt_tokens = estimate_tokens(prompt_text)
31
+ completion_tokens = estimate_tokens(response_text)
32
+
33
+ return {
34
+ "prompt_tokens": prompt_tokens,
35
+ "completion_tokens": completion_tokens,
36
+ "total_tokens": prompt_tokens + completion_tokens
37
+ }
v1_router.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Header, BackgroundTasks
2
+ from pydantic import BaseModel, Field
3
+ from typing import List, Optional, Union, Dict, Any
4
+ import time
5
+ import uuid
6
+
7
+ from config import DEMO_API_KEY
8
+ from db import get_supabase
9
+ from services import engine
10
+ from utils import calculate_usage
11
+
12
+ # Initialize Router
13
+ router = APIRouter()
14
+ # engine is imported from services
15
+
16
+ # --- Pydantic Models (OpenAI Spec) ---
17
+
18
+ class ChatMessage(BaseModel):
19
+ role: str
20
+ content: str
21
+ name: Optional[str] = None
22
+
23
+ class ChatCompletionRequest(BaseModel):
24
+ model: str
25
+ messages: List[ChatMessage]
26
+ temperature: Optional[float] = 1.0
27
+ top_p: Optional[float] = 1.0
28
+ n: Optional[int] = 1
29
+ stream: Optional[bool] = False
30
+ stop: Optional[Union[str, List[str]]] = None
31
+ max_tokens: Optional[int] = None
32
+ presence_penalty: Optional[float] = 0.0
33
+ frequency_penalty: Optional[float] = 0.0
34
+ logit_bias: Optional[Dict[str, float]] = None
35
+ user: Optional[str] = None
36
+
37
+ # Custom fields for our API (optional)
38
+ provider: Optional[str] = None
39
+
40
+ class ChatCompletionChoice(BaseModel):
41
+ index: int
42
+ message: ChatMessage
43
+ finish_reason: Optional[str] = "stop"
44
+
45
+ class UsageInfo(BaseModel):
46
+ prompt_tokens: int
47
+ completion_tokens: int
48
+ total_tokens: int
49
+
50
+ class ChatCompletionResponse(BaseModel):
51
+ id: str
52
+ object: str = "chat.completion"
53
+ created: int
54
+ model: str
55
+ choices: List[ChatCompletionChoice]
56
+ usage: UsageInfo
57
+
58
+
59
+ # --- Auth Dependency ---
60
+
61
+ async def verify_api_key(
62
+ authorization: Optional[str] = Header(None),
63
+ x_api_key: Optional[str] = Header(None)
64
+ ):
65
+ """
66
+ Verify Bearer Token or X-API-KEY.
67
+ Returns: key_data (dict) or None (if demo key)
68
+ Raises: HTTPException if invalid
69
+ """
70
+ token = None
71
+ if authorization:
72
+ parts = authorization.split()
73
+ if len(parts) == 2 and parts[0].lower() == "bearer":
74
+ token = parts[1]
75
+
76
+ if not token and x_api_key:
77
+ token = x_api_key
78
+
79
+ if not token:
80
+ raise HTTPException(status_code=401, detail="Missing API Key")
81
+
82
+ # 1. Check Demo Key
83
+ if token == DEMO_API_KEY:
84
+ return {"id": "demo", "name": "Demo User", "limit_tokens": -1}
85
+
86
+ # 2. Check Database
87
+ supabase = get_supabase()
88
+ if not supabase:
89
+ # Fallback if DB is down but key matches verified format? No, safer to reject.
90
+ raise HTTPException(status_code=503, detail="Auth service unavailable")
91
+
92
+ try:
93
+ # Check if key exists and is active
94
+ res = supabase.table("api_keys").select("*").eq("token", token).execute()
95
+
96
+ if not res.data:
97
+ raise HTTPException(status_code=401, detail="Invalid API Key")
98
+
99
+ key_data = res.data[0]
100
+
101
+ if not key_data.get("is_active", True):
102
+ raise HTTPException(status_code=403, detail="API Key is inactive")
103
+
104
+ # Check limits
105
+ # Note: We check limit BEFORE processing, but update usage AFTER (bg task)
106
+ current_usage = key_data.get("usage_tokens", 0)
107
+ limit = key_data.get("limit_tokens", 0)
108
+
109
+ if limit > 0 and current_usage >= limit:
110
+ raise HTTPException(status_code=429, detail="Quota exceeded")
111
+
112
+ return key_data
113
+
114
+ except Exception as e:
115
+ print(f"Auth Error: {e}")
116
+ raise HTTPException(status_code=500, detail="Auth Error")
117
+
118
+ # --- Background Task for Usage Update ---
119
+
120
+ def update_usage_stats(key_id: str, tokens: int):
121
+ """Increment token usage in DB."""
122
+ if key_id == "demo":
123
+ return # Don't track demo usage in DB (or maybe track in a separate table later)
124
+
125
+ supabase = get_supabase()
126
+ if supabase and tokens > 0:
127
+ try:
128
+ # Atomic increment? Supabase (Postgres) supports it via RPC or simple update if inaccurate is okay.
129
+ # Best practice: use RPC. For now simple update read-modify-write (concurrency risk but okay for low volume)
130
+ # Actually, let's just do a simple increment if possible, or fetch-add
131
+
132
+ # Since we can't easily do RPC without creating it in SQL first, let's just do Python-side increment
133
+ # (Valid since we have the key_data from verification, but it might be stale)
134
+
135
+ # Better: create an RPC function later. For now, just logging it.
136
+ # The implementation plan implied we track it.
137
+
138
+ # Let's try to get fresh usage and update.
139
+ current = supabase.table("api_keys").select("usage_tokens").eq("id", key_id).execute()
140
+ if current.data:
141
+ new_total = (current.data[0]['usage_tokens'] or 0) + tokens
142
+ supabase.table("api_keys").update({"usage_tokens": new_total}).eq("id", key_id).execute()
143
+
144
+ except Exception as e:
145
+ print(f"Failed to update usage for {key_id}: {e}")
146
+
147
+ # --- Endpoint ---
148
+
149
+ @router.post("/v1/chat/completions", response_model=ChatCompletionResponse)
150
+ async def chat_completions(
151
+ request: ChatCompletionRequest,
152
+ background_tasks: BackgroundTasks,
153
+ key_data: dict = Depends(verify_api_key)
154
+ ):
155
+ """
156
+ OpenAI-compatible Chat Completion Endpoint.
157
+ """
158
+ # Convert messages list to simple prompt (or keep as list if engine supports it)
159
+ # Our engine currently takes a single prompt string + optional system prompt.
160
+
161
+ system_prompt = None
162
+ user_prompt = ""
163
+
164
+ # Simple conversion logic
165
+ for m in request.messages:
166
+ if m.role == "system":
167
+ system_prompt = m.content
168
+ elif m.role == "user":
169
+ if user_prompt:
170
+ user_prompt += f"\n\n[User]: {m.content}"
171
+ else:
172
+ user_prompt = m.content
173
+ elif m.role == "assistant":
174
+ user_prompt += f"\n\n[Assistant]: {m.content}"
175
+
176
+ # Call Engine
177
+ provider = request.provider or "auto"
178
+
179
+ try:
180
+ result = await engine.chat(
181
+ prompt=user_prompt,
182
+ model=request.model,
183
+ provider=provider,
184
+ system_prompt=system_prompt
185
+ )
186
+
187
+ response_text = result["response"]
188
+ actual_model = result["model"]
189
+
190
+ # Calculate Usage
191
+ usage = calculate_usage([m.dict() for m in request.messages], response_text)
192
+
193
+ # Background: Update DB
194
+ background_tasks.add_task(update_usage_stats, key_data["id"], usage["total_tokens"])
195
+
196
+ # Construct Response
197
+ return ChatCompletionResponse(
198
+ id=f"chatcmpl-{uuid.uuid4().hex[:8]}",
199
+ created=int(time.time()),
200
+ model=actual_model,
201
+ choices=[
202
+ ChatCompletionChoice(
203
+ index=0,
204
+ message=ChatMessage(role="assistant", content=response_text),
205
+ finish_reason="stop"
206
+ )
207
+ ],
208
+ usage=UsageInfo(**usage)
209
+ )
210
+
211
+ except Exception as e:
212
+ raise HTTPException(status_code=500, detail=str(e))