Feat: Implement token tracking, soft delete, and Admin UI improvements

This commit is contained in:
2026-02-10 01:00:26 +08:00
parent 6924e86b8d
commit 968eb173dd
14 changed files with 1763 additions and 180 deletions

View File

@@ -0,0 +1,44 @@
---
description: How to set up the local development environment using pyenv
---
1. **Install Python**
Install a compatible Python version (e.g., 3.11.9):
```powershell
pyenv install 3.11.9
```
2. **Set Local Version**
Set the local python version for this project:
```powershell
pyenv local 3.11.9
```
3. **Check Version**
Verify the correct version is active:
```powershell
python --version
```
4. **Create Virtual Environment**
Create a virtual environment to isolate dependencies:
```powershell
python -m venv .venv
```
5. **Activate Environment**
Activate the virtual environment:
```powershell
.\.venv\Scripts\activate
```
6. **Install Dependencies**
Install the required packages:
```powershell
pip install -r requirements.txt
```
7. **Run Application**
Start the server using the run script:
```powershell
python run.py
```

View File

@@ -0,0 +1,27 @@
---
description: How to run the application using uv
---
1. **Initialize Project** (Already done)
If you haven't already:
```powershell
uv init
```
2. **Install Dependencies** (Already done)
Install required packages:
```powershell
uv add -r requirements.txt
```
3. **Run Application**
You can run the application directly using `uv run`. This automatically uses the virtual environment managed by `uv`.
**Option A: Using the run script (Recommended)**
```powershell
uv run run.py
```
**Option B: Using Uvicorn directly**
```powershell
uv run uvicorn app.main:app --reload
```

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

0
README.md Normal file
View File

View File

@@ -26,6 +26,9 @@ class ModuleResponse(BaseModel):
is_active: bool is_active: bool
created_at: datetime created_at: datetime
last_rotated_at: datetime last_rotated_at: datetime
ingress_tokens: int
egress_tokens: int
total_tokens: int
class Config: class Config:
from_attributes = True from_attributes = True
@@ -55,10 +58,14 @@ def create_module(
@router.get("/modules", response_model=List[ModuleResponse]) @router.get("/modules", response_model=List[ModuleResponse])
def get_modules( def get_modules(
include_archived: bool = False,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: str = Depends(get_current_user) current_user: str = Depends(get_current_user)
): ):
return db.query(Module).all() query = db.query(Module)
if not include_archived:
query = query.filter(Module.is_active == True)
return query.all()
@router.post("/modules/{module_id}/rotate", response_model=ModuleResponse) @router.post("/modules/{module_id}/rotate", response_model=ModuleResponse)
def rotate_module_key( def rotate_module_key(
@@ -79,12 +86,35 @@ def rotate_module_key(
@router.delete("/modules/{module_id}") @router.delete("/modules/{module_id}")
def delete_module( def delete_module(
module_id: int, module_id: int,
hard_delete: bool = False,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: str = Depends(get_current_user) current_user: str = Depends(get_current_user)
): ):
db_module = db.query(Module).filter(Module.id == module_id).first() db_module = db.query(Module).filter(Module.id == module_id).first()
if not db_module: if not db_module:
raise HTTPException(status_code=404, detail="Module not found") raise HTTPException(status_code=404, detail="Module not found")
if hard_delete:
db.delete(db_module) db.delete(db_module)
message = "Module permanently deleted"
else:
db_module.is_active = False
message = "Module archived"
db.commit() db.commit()
return {"status": "success"} return {"status": "success", "message": message}
@router.post("/modules/{module_id}/restore", response_model=ModuleResponse)
def restore_module(
module_id: int,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user)
):
db_module = db.query(Module).filter(Module.id == module_id).first()
if not db_module:
raise HTTPException(status_code=404, detail="Module not found")
db_module.is_active = True
db.commit()
db.refresh(db_module)
return db_module

View File

@@ -33,11 +33,35 @@ async def get_api_key(
# 3. Check Database for Module key (Database round-trip) # 3. Check Database for Module key (Database round-trip)
module = db.query(Module).filter(Module.secret_key == api_key_header, Module.is_active == True).first() module = db.query(Module).filter(Module.secret_key == api_key_header, Module.is_active == True).first()
if module: if module:
# Save to cache for next time # Save module ID to cache for next time
auth_cache[api_key_header] = True auth_cache[api_key_header] = module.id
return api_key_header return module
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials or API Key is inactive" detail="Could not validate credentials or API Key is inactive"
) )
async def get_current_module(
api_key_header: str = Security(api_key_header),
db: Session = Depends(get_db)
):
# 1. Fallback to global static key (Admin) - No module tracking
if api_key_header == settings.API_KEY:
return None
# 2. Check Cache
if api_key_header in auth_cache:
module_id = auth_cache[api_key_header]
return db.query(Module).filter(Module.id == module_id).first()
# 3. DB Lookup
module = db.query(Module).filter(Module.secret_key == api_key_header, Module.is_active == True).first()
if module:
auth_cache[api_key_header] = module.id
return module
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials"
)

View File

@@ -1,5 +1,8 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from app.api.deps import get_api_key from app.api.deps import get_api_key, get_current_module
from app.models.module import Module
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.limiter import limiter from app.core.limiter import limiter
from app.core.config import settings from app.core.config import settings
from pydantic import BaseModel from pydantic import BaseModel
@@ -26,16 +29,29 @@ def get_gemini_client():
async def gemini_chat( async def gemini_chat(
request: Request, request: Request,
chat_data: LLMRequest, chat_data: LLMRequest,
api_key: str = Depends(get_api_key) api_key: str = Depends(get_api_key),
module: Module = Depends(get_current_module),
db: Session = Depends(get_db)
): ):
client = get_gemini_client() client = get_gemini_client()
try: try:
if not client: if not client:
# Mock response
response_text = f"MOCK: Gemini response to '{chat_data.prompt}'"
if module:
# Estimate tokens for mock
prompt_tokens = len(chat_data.prompt) // 4
completion_tokens = len(response_text) // 4
module.ingress_tokens += prompt_tokens
module.egress_tokens += completion_tokens
module.total_tokens += (prompt_tokens + completion_tokens)
db.commit()
return { return {
"status": "mock", "status": "mock",
"model": "gemini", "model": "gemini",
"response": f"MOCK: Gemini response to '{chat_data.prompt}'" "response": response_text
} }
# Using the async generation method provided by the new google-genai library # Using the async generation method provided by the new google-genai library
@@ -45,6 +61,20 @@ async def gemini_chat(
contents=chat_data.prompt contents=chat_data.prompt
) )
# Track usage if valid module
if module:
# Estimate tokens since metadata might vary
# 1 char ~= 0.25 tokens (rough estimate if exact count not returned)
# Gemini response usually has usage_metadata
usage = response.usage_metadata
prompt_tokens = usage.prompt_token_count if usage else len(chat_data.prompt) // 4
completion_tokens = usage.candidates_token_count if usage else len(response.text) // 4
module.ingress_tokens += prompt_tokens
module.egress_tokens += completion_tokens
module.total_tokens += (prompt_tokens + completion_tokens)
db.commit()
return { return {
"status": "success", "status": "success",
"model": "gemini", "model": "gemini",

View File

@@ -1,5 +1,8 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from app.api.deps import get_api_key from app.api.deps import get_api_key, get_current_module
from app.models.module import Module
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.limiter import limiter from app.core.limiter import limiter
from app.core.config import settings from app.core.config import settings
from pydantic import BaseModel from pydantic import BaseModel
@@ -22,14 +25,27 @@ if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key":
async def openai_chat( async def openai_chat(
request: Request, request: Request,
chat_data: LLMRequest, chat_data: LLMRequest,
api_key: str = Depends(get_api_key) api_key: str = Depends(get_api_key),
module: Module = Depends(get_current_module),
db: Session = Depends(get_db)
): ):
try: try:
if not client: if not client:
# Mock response
response_text = f"MOCK: OpenAI response to '{chat_data.prompt}'"
if module:
# Estimate tokens for mock
prompt_tokens = len(chat_data.prompt) // 4
completion_tokens = len(response_text) // 4
module.ingress_tokens += prompt_tokens
module.egress_tokens += completion_tokens
module.total_tokens += (prompt_tokens + completion_tokens)
db.commit()
return { return {
"status": "mock", "status": "mock",
"model": "openai", "model": "openai",
"response": f"MOCK: OpenAI response to '{chat_data.prompt}'" "response": response_text
} }
# Perform Async call to OpenAI # Perform Async call to OpenAI
@@ -37,6 +53,16 @@ async def openai_chat(
model="gpt-3.5-turbo", model="gpt-3.5-turbo",
messages=[{"role": "user", "content": chat_data.prompt}] messages=[{"role": "user", "content": chat_data.prompt}]
) )
# Track usage
if module:
usage = response.usage
if usage:
module.ingress_tokens += usage.prompt_tokens
module.egress_tokens += usage.completion_tokens
module.total_tokens += usage.total_tokens
db.commit()
return { return {
"status": "success", "status": "success",
"model": "openai", "model": "openai",

View File

@@ -14,3 +14,8 @@ class Module(Base):
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
last_rotated_at = Column(DateTime, default=datetime.utcnow) last_rotated_at = Column(DateTime, default=datetime.utcnow)
# Token usage tracking
ingress_tokens = Column(Integer, default=0)
egress_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)

View File

@@ -47,7 +47,7 @@
} }
.container { .container {
max-width: 1000px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
width: 100%; width: 100%;
@@ -154,6 +154,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
margin-bottom: 2rem; margin-bottom: 2rem;
animation: fadeInUp 0.5s ease-out; animation: fadeInUp 0.5s ease-out;
overflow-x: auto;
} }
.flex-header { .flex-header {
@@ -163,65 +164,61 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.module-grid { /* Table Styles */
display: grid; table {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); width: 100%;
gap: 1.5rem; border-collapse: collapse;
color: var(--text-main);
} }
.module-card { th,
background: rgba(15, 23, 42, 0.3); td {
border: 1px solid var(--border); padding: 1rem;
border-radius: 16px; text-align: left;
padding: 1.5rem; border-bottom: 1px solid var(--border);
transition: all 0.3s ease;
} }
.module-card:hover { th {
border-color: rgba(99, 102, 241, 0.4);
transform: translateY(-5px);
}
.module-name {
font-weight: 600; font-weight: 600;
font-size: 1.125rem; color: var(--primary);
margin-bottom: 0.5rem; text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
} }
.key-box { tr:last-child td {
background: #0f172a; border-bottom: none;
padding: 1rem 0.75rem; }
border-radius: 12px;
tr:hover {
background-color: rgba(255, 255, 255, 0.02);
}
.key-cell {
font-family: 'Monaco', 'Consolas', monospace; font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem; font-size: 0.8rem;
color: #818cf8; color: #818cf8;
margin: 1.25rem 0; white-space: nowrap;
word-break: break-all;
border: 1px solid var(--border);
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
} }
.key-text { .key-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none; user-select: none;
display: inline-block;
} }
.key-text.visible { .key-text.visible {
white-space: normal; user-select: text;
word-break: break-all;
} }
.icon-actions { .token-cell {
font-family: 'Monaco', 'Consolas', monospace;
text-align: right;
}
.actions-cell {
display: flex; display: flex;
gap: 8px; gap: 0.5rem;
flex-shrink: 0; justify-content: flex-end;
} }
.icon-btn { .icon-btn {
@@ -232,31 +229,23 @@
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
width: 32px;
height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px;
height: 32px;
} }
.icon-btn:hover { .icon-btn:hover {
background: rgba(99, 102, 241, 0.1); background: rgba(99, 102, 241, 0.1);
border-color: var(--primary); border-color: var(--primary);
color: var(--primary); color: var(--primary);
transform: scale(1.05);
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
} }
.btn-small { .btn-small {
padding: 0.6rem 1.2rem;
font-size: 0.85rem;
width: auto; width: auto;
border-radius: 10px; padding: 0.5rem 1rem;
font-size: 0.85rem;
} }
.btn-outline { .btn-outline {
@@ -271,49 +260,17 @@
} }
.btn-danger { .btn-danger {
background: #ef4444; background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #ef4444;
} }
.btn-danger:hover { .btn-danger:hover {
background: #dc2626; background: #ef4444;
} color: white;
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
} }
/* Modal Styles */
.modal { .modal {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -355,24 +312,18 @@
margin-top: 2rem; margin-top: 2rem;
} }
.module-meta { .hidden {
display: flex; display: none !important;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
} }
.badge { .badge {
background: rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.15);
color: #a5b4fc; color: #a5b4fc;
padding: 4px 8px; padding: 2px 6px;
border-radius: 6px; border-radius: 4px;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
} margin-right: 4px;
.hidden {
display: none !important;
} }
.toast { .toast {
@@ -385,12 +336,48 @@
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
transform: translateY(100px); transform: translateY(100px);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000; z-index: 3000;
} }
.toast.show { .toast.show {
transform: translateY(0); transform: translateY(0);
} }
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style> </style>
</head> </head>
@@ -422,17 +409,36 @@
<section id="dashboard-section"> <section id="dashboard-section">
<div class="card"> <div class="card">
<div class="flex-header"> <div class="flex-header">
<h2>E-Learning Modules</h2> <h2>Modules & Token Usage</h2>
<div style="display:flex; gap:1rem; align-items:center;">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer; margin-bottom:0;">
<input type="checkbox" id="show-archived-toggle" style="width:auto; margin:0;">
<span style="font-size:0.9rem; color:var(--text-dim);">Show Archived</span>
</label>
<button id="add-module-btn" class="btn-small">Create New Module</button> <button id="add-module-btn" class="btn-small">Create New Module</button>
</div> </div>
<div id="module-list" class="module-grid">
<!-- Modules will be injected here -->
</div> </div>
<table id="modules-table">
<thead>
<tr>
<th>Module Name</th>
<th>API Key</th>
<th style="text-align: right;">Ingress</th>
<th style="text-align: right;">Egress</th>
<th style="text-align: right;">Total</th>
<th>Meta</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="module-list-body">
<!-- Rows injected here -->
</tbody>
</table>
</div> </div>
</section> </section>
</div> </div>
<!-- Create Module Modal -->
<div id="create-modal" class="modal hidden"> <div id="create-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
<h2>Create New Module</h2> <h2>Create New Module</h2>
@@ -461,6 +467,36 @@
</div> </div>
</div> </div>
<!-- View Key Modal -->
<div id="view-key-modal" class="modal hidden">
<div class="modal-content">
<h2>API Key</h2>
<p style="color: var(--text-dim); margin-bottom: 1rem;">Use this key to authenticate your API requests.</p>
<div class="form-group">
<div style="background: rgba(15, 23, 42, 0.5); padding: 1rem; border-radius: 12px; border: 1px solid var(--border); font-family: monospace; word-break: break-all; color: #818cf8; user-select: text;"
id="modal-key-display">
<!-- Key injected here -->
</div>
</div>
<div class="modal-actions">
<button id="close-key-modal-btn" class="btn-small btn-outline">Close</button>
<button id="copy-modal-key-btn" class="btn-small">Copy Key</button>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal hidden">
<div class="modal-content" style="max-width: 400px;">
<h2 id="confirm-title">Confirm Action</h2>
<p id="confirm-message" style="color: var(--text-dim); margin-bottom: 2rem;">Are you sure?</p>
<div class="modal-actions">
<button id="confirm-cancel-btn" class="btn-small btn-outline">Cancel</button>
<button id="confirm-ok-btn" class="btn-small btn-danger">Confirm</button>
</div>
</div>
</div>
<div id="toast" class="toast">Action successful!</div> <div id="toast" class="toast">Action successful!</div>
<script> <script>
@@ -469,7 +505,7 @@
const loginSection = document.getElementById('login-section'); const loginSection = document.getElementById('login-section');
const dashboardSection = document.getElementById('dashboard-section'); const dashboardSection = document.getElementById('dashboard-section');
const logoutBtn = document.getElementById('logout-btn'); const logoutBtn = document.getElementById('logout-btn');
const moduleList = document.getElementById('module-list'); const moduleListBody = document.getElementById('module-list-body');
function showToast(msg) { function showToast(msg) {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');
@@ -478,6 +514,10 @@
setTimeout(() => toast.classList.remove('show'), 3000); setTimeout(() => toast.classList.remove('show'), 3000);
} }
function formatNumber(num) {
return new Intl.NumberFormat().format(num);
}
async function apiRequest(endpoint, method = 'GET', body = null) { async function apiRequest(endpoint, method = 'GET', body = null) {
const options = { const options = {
method, method,
@@ -495,6 +535,13 @@
logout(); logout();
return null; return null;
} }
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
console.error("API Error:", err);
// Only alert if it's not a 401 (already handled)
alert(`Error ${response.status}: ${err.detail || 'Unknown error'}`);
return null;
}
return response.json(); return response.json();
} }
@@ -540,38 +587,72 @@
loadModules(); loadModules();
} }
const showArchivedToggle = document.getElementById('show-archived-toggle');
showArchivedToggle.onchange = loadModules;
async function loadModules() { async function loadModules() {
const modules = await apiRequest('/internal/admin/modules'); const includeArchived = showArchivedToggle.checked;
const endpoint = includeArchived ? '/internal/admin/modules?include_archived=true' : '/internal/admin/modules';
const modules = await apiRequest(endpoint);
if (!modules) return; if (!modules) return;
moduleList.innerHTML = modules.map(m => ` moduleListBody.innerHTML = modules.map(m => {
<div class="module-card"> const isArchived = !m.is_active;
<div class="module-name">${m.name}</div> const rowStyle = isArchived ? 'opacity: 0.6; background: rgba(0,0,0,0.2);' : '';
<div class="text-dim" style="font-size: 0.8rem">Created: ${new Date(m.created_at).toLocaleDateString()}</div>
<div class="module-meta"> return `
<tr style="${rowStyle}">
<td>
<div style="font-weight: 600;">
${m.name}
${isArchived ? '<span class="badge" style="background:rgba(239,68,68,0.2); color:#ef4444; margin-left:8px;">ARCHIVED</span>' : ''}
</div>
<div class="text-dim" style="font-size: 0.75rem">Created: ${new Date(m.created_at).toLocaleDateString()}</div>
</td>
<td>
<div class="key-cell">
<span class="key-text" id="key-${m.id}">••••••••</span>
<button class="icon-btn" onclick="viewKey('${m.secret_key}')" title="View Key" style="display:inline-flex; width:24px; height:24px; vertical-align:middle; margin-left:8px;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
<button class="icon-btn" onclick="copyKey('${m.secret_key}')" title="Copy Key" style="display:inline-flex; width:24px; height:24px; vertical-align:middle; margin-left:4px;">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
</button>
</div>
</td>
<td class="token-cell" title="Ingress Tokens">${formatNumber(m.ingress_tokens)}</td>
<td class="token-cell" title="Egress Tokens">${formatNumber(m.egress_tokens)}</td>
<td class="token-cell" style="color: var(--primary); font-weight:bold;" title="Total Tokens">${formatNumber(m.total_tokens)}</td>
<td>
<div style="display:flex; flex-wrap:wrap; gap:4px; max-width:200px;">
${m.program ? `<span class="badge" title="Program">${m.program}</span>` : ''} ${m.program ? `<span class="badge" title="Program">${m.program}</span>` : ''}
${m.lob ? `<span class="badge" title="Line of Business">${m.lob}</span>` : ''} ${m.lob ? `<span class="badge" title="Line of Business">${m.lob}</span>` : ''}
${m.job_code ? `<span class="badge" title="Job Code">${m.job_code}</span>` : ''} ${m.job_code ? `<span class="badge" title="Job Code">${m.job_code}</span>` : ''}
</div> </div>
</td>
<div class="key-box"> <td>
<span class="key-text" id="key-${m.id}">••••••••••••••••••••••••••••••••</span> <div class="actions-cell">
<div class="icon-actions"> ${!isArchived ? `
<button class="icon-btn" onclick="toggleKey(${m.id}, '${m.secret_key}')" title="Show/Hide Key"> <button class="icon-btn" onclick="rotateKey(${m.id})" title="Rotate Key">
<svg id="eye-icon-${m.id}" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 16h5v5"></path></svg>
</button> </button>
<button class="icon-btn" onclick="copyKey('${m.secret_key}')" title="Copy Key"> <button class="icon-btn btn-danger" onclick="archiveModule(${m.id})" title="Archive Module">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
</button> </button>
` : `
<button class="icon-btn" onclick="restoreModule(${m.id})" title="Restore Module" style="color:#22c55e; border-color: #22c55e; background: rgba(34,197,94,0.1);">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 15 14 15 8"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path><path d="M4 20v-9"></path></svg>
</button>
<button class="icon-btn btn-danger" onclick="hardDeleteModule(${m.id})" title="Permanently Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"></path></svg>
</button>
`}
</div> </div>
</div> </td>
<div class="actions"> </tr>
<button class="btn-small btn-outline" onclick="rotateKey(${m.id})">Rotate Key</button> `;
<button class="btn-small btn-danger" onclick="deleteModule(${m.id})">Delete</button> }).join('');
</div>
</div>
`).join('');
} }
// Modal Logic // Modal Logic
@@ -613,20 +694,24 @@
} }
}; };
window.toggleKey = (id, key) => { // View Key Modal Logic
const el = document.getElementById(`key-${id}`); const viewKeyModal = document.getElementById('view-key-modal');
const icon = document.getElementById(`eye-icon-${id}`); const closeKeyModalBtn = document.getElementById('close-key-modal-btn');
const isHidden = el.innerText.includes('•'); const copyModalKeyBtn = document.getElementById('copy-modal-key-btn');
const modalKeyDisplay = document.getElementById('modal-key-display');
let currentKeyToCopy = '';
if (isHidden) { closeKeyModalBtn.onclick = () => viewKeyModal.classList.add('hidden');
el.innerText = key;
el.classList.add('visible'); copyModalKeyBtn.onclick = () => {
icon.innerHTML = '<path d="M9.88 9.88 2 20"></path><path d="M2 12s3-7 10-7a9.77 9.77 0 0 1 5 1.45"></path><path d="M22 12s-3 7-10 7a9.77 9.77 0 0 1-5-1.45"></path><path d="M15.12 15.12a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>'; navigator.clipboard.writeText(currentKeyToCopy);
} else { showToast('Key copied to clipboard!');
el.innerText = '••••••••••••••••••••••••••••••••'; };
el.classList.remove('visible');
icon.innerHTML = '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>'; window.viewKey = (key) => {
} currentKeyToCopy = key;
modalKeyDisplay.innerText = key;
viewKeyModal.classList.remove('hidden');
}; };
window.copyKey = (key) => { window.copyKey = (key) => {
@@ -634,23 +719,95 @@
showToast('Key copied to clipboard!'); showToast('Key copied to clipboard!');
}; };
window.rotateKey = async (id) => { // Confirmation Modal Logic
if (confirm('Are you sure? Previous key will stop working immediately.')) { const confirmModal = document.getElementById('confirm-modal');
const confirmTitle = document.getElementById('confirm-title');
const confirmMessage = document.getElementById('confirm-message');
const confirmOkBtn = document.getElementById('confirm-ok-btn');
const confirmCancelBtn = document.getElementById('confirm-cancel-btn');
let onConfirmHandler = null;
function openConfirmModal(title, message, isDanger, handler) {
confirmTitle.innerText = title;
confirmMessage.innerText = message;
onConfirmHandler = handler;
if (isDanger) {
confirmOkBtn.classList.add('btn-danger');
confirmOkBtn.style.backgroundColor = ''; // Reset inline style if any
} else {
confirmOkBtn.classList.remove('btn-danger');
confirmOkBtn.style.backgroundColor = 'var(--primary)';
}
confirmModal.classList.remove('hidden');
}
confirmCancelBtn.onclick = () => {
confirmModal.classList.add('hidden');
onConfirmHandler = null;
};
confirmOkBtn.onclick = async () => {
if (onConfirmHandler) {
await onConfirmHandler();
}
confirmModal.classList.add('hidden');
onConfirmHandler = null;
};
window.rotateKey = (id) => {
openConfirmModal(
'Rotate API Key',
'Are you sure? Previous key will stop working immediately.',
false,
async () => {
await apiRequest(`/internal/admin/modules/${id}/rotate`, 'POST'); await apiRequest(`/internal/admin/modules/${id}/rotate`, 'POST');
showToast('Key rotated successfully'); showToast('Key rotated successfully');
loadModules(); loadModules();
} }
);
}; };
window.deleteModule = async (id) => { window.archiveModule = (id) => {
if (confirm('Delete this module? This cannot be undone.')) { openConfirmModal(
'Archive Module',
'Archive this module? It will stop working immediately.',
true,
async () => {
await apiRequest(`/internal/admin/modules/${id}`, 'DELETE'); await apiRequest(`/internal/admin/modules/${id}`, 'DELETE');
showToast('Module deleted'); showToast('Module archived');
loadModules(); loadModules();
} }
);
}; };
document.getElementById('login-btn').onclick = login; window.restoreModule = (id) => {
openConfirmModal(
'Restore Module',
'Restore this module? API access will be re-enabled.',
false,
async () => {
await apiRequest(`/internal/admin/modules/${id}/restore`, 'POST');
showToast('Module restored');
loadModules();
}
);
};
window.hardDeleteModule = (id) => {
openConfirmModal(
'Permanent Delete',
'PERMANENTLY DELETE this module? This cannot be undone.',
true,
async () => {
await apiRequest(`/internal/admin/modules/${id}?hard_delete=true`, 'DELETE');
showToast('Module permanently deleted');
loadModules();
}
);
};
document.getElementById('login-btn').onclick = login; document.getElementById('login-btn').onclick = login;
logoutBtn.onclick = logout; logoutBtn.onclick = logout;

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "ai-gateway"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.18.3",
"cachetools>=7.0.0",
"fastapi>=0.128.5",
"google-genai>=1.62.0",
"httpx>=0.28.1",
"openai>=2.17.0",
"passlib[bcrypt]>=1.7.4",
"psycopg2-binary>=2.9.11",
"python-dotenv>=1.2.1",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.22",
"slowapi>=0.1.9",
"sqlalchemy>=2.0.46",
"uvicorn>=0.40.0",
]

20
run.py
View File

@@ -6,21 +6,21 @@ from dotenv import load_dotenv
def setup_environment(): def setup_environment():
"""Ensure dependencies are installed and .env exists.""" """Ensure dependencies are installed and .env exists."""
print("🚀 Initializing Storyline AI Gateway...") print(">> Initializing Storyline AI Gateway...")
# 1. Check for .env file # 1. Check for .env file
if not os.path.exists(".env"): if not os.path.exists(".env"):
print("⚠️ Warning: .env file not found!") print("!! Warning: .env file not found!")
print("Please create a .env file based on the documentation.") print("Please create a .env file based on the documentation.")
# 2. Check for virtual environment and install dependencies if requested # 2. Check for virtual environment and install dependencies if requested
if len(sys.argv) > 1 and sys.argv[1] == "--install": if len(sys.argv) > 1 and sys.argv[1] == "--install":
print("📦 Installing dependencies from requirements.txt...") print(">> Installing dependencies from requirements.txt...")
try: try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"]) subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
print(" Dependencies installed successfully.") print("++ Dependencies installed successfully.")
except Exception as e: except Exception as e:
print(f" Error installing dependencies: {e}") print(f"-- Error installing dependencies: {e}")
sys.exit(1) sys.exit(1)
def run_server(): def run_server():
@@ -28,16 +28,16 @@ def run_server():
load_dotenv() load_dotenv()
# Configuration # Configuration
host = os.getenv("HOST", "0.0.0.0") host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", 8000)) port = int(os.getenv("PORT", 8000))
# On a server, we usually don't want reload=True by default for performance # On a server, we usually don't want reload=True by default for performance
# But for a 'test server', it might be useful. # But for a 'test server', it might be useful.
reload = os.getenv("DEBUG", "false").lower() == "true" reload = os.getenv("DEBUG", "false").lower() == "true"
print(f"\n🌐 Gateway starting at http://{host}:{port}") print(f"\n>> Gateway starting at http://{host}:{port}")
print(f"🛠️ Admin Dashboard: http://{host}:{port}/admin") print(f">> Admin Dashboard: http://{host}:{port}/admin")
print(f"📚 API Docs: http://{host}:{port}/docs") print(f">> API Docs: http://{host}:{port}/docs")
print(f"🔄 Mode: {'Development (Reload On)' if reload else 'Production (Reload Off)'}\n") print(f">> Mode: {'Development (Reload On)' if reload else 'Production (Reload Off)'}\n")
uvicorn.run( uvicorn.run(
"app.main:app", "app.main:app",

30
update_schema.py Normal file
View File

@@ -0,0 +1,30 @@
from sqlalchemy import create_engine, text
from app.core.config import settings
def update_schema():
engine = create_engine(settings.DATABASE_URL)
with engine.connect() as conn:
print("Adding token tracking columns to modules table...")
try:
conn.execute(text("ALTER TABLE modules ADD COLUMN ingress_tokens INTEGER DEFAULT 0"))
print("Added ingress_tokens column.")
except Exception as e:
print(f"ingress_tokens column might already exist: {e}")
try:
conn.execute(text("ALTER TABLE modules ADD COLUMN egress_tokens INTEGER DEFAULT 0"))
print("Added egress_tokens column.")
except Exception as e:
print(f"egress_tokens column might already exist: {e}")
try:
conn.execute(text("ALTER TABLE modules ADD COLUMN total_tokens INTEGER DEFAULT 0"))
print("Added total_tokens column.")
except Exception as e:
print(f"total_tokens column might already exist: {e}")
conn.commit()
print("Schema update complete.")
if __name__ == "__main__":
update_schema()

1187
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff