Feat: Implement token tracking, soft delete, and Admin UI improvements
This commit is contained in:
44
.agent/workflows/setup_local_env.md
Normal file
44
.agent/workflows/setup_local_env.md
Normal 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
|
||||
```
|
||||
27
.agent/workflows/setup_with_uv.md
Normal file
27
.agent/workflows/setup_with_uv.md
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -26,6 +26,9 @@ class ModuleResponse(BaseModel):
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_rotated_at: datetime
|
||||
ingress_tokens: int
|
||||
egress_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -55,10 +58,14 @@ def create_module(
|
||||
|
||||
@router.get("/modules", response_model=List[ModuleResponse])
|
||||
def get_modules(
|
||||
include_archived: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
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)
|
||||
def rotate_module_key(
|
||||
@@ -79,12 +86,35 @@ def rotate_module_key(
|
||||
@router.delete("/modules/{module_id}")
|
||||
def delete_module(
|
||||
module_id: int,
|
||||
hard_delete: bool = False,
|
||||
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")
|
||||
|
||||
if hard_delete:
|
||||
db.delete(db_module)
|
||||
message = "Module permanently deleted"
|
||||
else:
|
||||
db_module.is_active = False
|
||||
message = "Module archived"
|
||||
|
||||
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
|
||||
|
||||
@@ -33,11 +33,35 @@ async def get_api_key(
|
||||
# 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()
|
||||
if module:
|
||||
# Save to cache for next time
|
||||
auth_cache[api_key_header] = True
|
||||
return api_key_header
|
||||
# Save module ID to cache for next time
|
||||
auth_cache[api_key_header] = module.id
|
||||
return module
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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.config import settings
|
||||
from pydantic import BaseModel
|
||||
@@ -26,16 +29,29 @@ def get_gemini_client():
|
||||
async def gemini_chat(
|
||||
request: Request,
|
||||
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()
|
||||
|
||||
try:
|
||||
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 {
|
||||
"status": "mock",
|
||||
"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
|
||||
@@ -45,6 +61,20 @@ async def gemini_chat(
|
||||
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 {
|
||||
"status": "success",
|
||||
"model": "gemini",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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.config import settings
|
||||
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(
|
||||
request: Request,
|
||||
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:
|
||||
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 {
|
||||
"status": "mock",
|
||||
"model": "openai",
|
||||
"response": f"MOCK: OpenAI response to '{chat_data.prompt}'"
|
||||
"response": response_text
|
||||
}
|
||||
|
||||
# Perform Async call to OpenAI
|
||||
@@ -37,6 +53,16 @@ async def openai_chat(
|
||||
model="gpt-3.5-turbo",
|
||||
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 {
|
||||
"status": "success",
|
||||
"model": "openai",
|
||||
|
||||
@@ -14,3 +14,8 @@ class Module(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_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)
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
@@ -154,6 +154,7 @@
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 2rem;
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.flex-header {
|
||||
@@ -163,65 +164,61 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
/* Table Styles */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.module-card {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
th,
|
||||
td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.module-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.module-name {
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.key-box {
|
||||
background: #0f172a;
|
||||
padding: 1rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.key-cell {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #818cf8;
|
||||
margin: 1.25rem 0;
|
||||
word-break: break-all;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.key-text.visible {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.icon-actions {
|
||||
.token-cell {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -232,31 +229,23 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
width: auto;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@@ -271,49 +260,17 @@
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -355,24 +312,18 @@
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.module-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #a5b4fc;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@@ -385,12 +336,48 @@
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(100px);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1000;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
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>
|
||||
</head>
|
||||
|
||||
@@ -422,17 +409,36 @@
|
||||
<section id="dashboard-section">
|
||||
<div class="card">
|
||||
<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>
|
||||
</div>
|
||||
<div id="module-list" class="module-grid">
|
||||
<!-- Modules will be injected here -->
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Create Module Modal -->
|
||||
<div id="create-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h2>Create New Module</h2>
|
||||
@@ -461,6 +467,36 @@
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -469,7 +505,7 @@
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const dashboardSection = document.getElementById('dashboard-section');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const moduleList = document.getElementById('module-list');
|
||||
const moduleListBody = document.getElementById('module-list-body');
|
||||
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toast');
|
||||
@@ -478,6 +514,10 @@
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
return new Intl.NumberFormat().format(num);
|
||||
}
|
||||
|
||||
async function apiRequest(endpoint, method = 'GET', body = null) {
|
||||
const options = {
|
||||
method,
|
||||
@@ -495,6 +535,13 @@
|
||||
logout();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -540,38 +587,72 @@
|
||||
loadModules();
|
||||
}
|
||||
|
||||
const showArchivedToggle = document.getElementById('show-archived-toggle');
|
||||
|
||||
showArchivedToggle.onchange = 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;
|
||||
|
||||
moduleList.innerHTML = modules.map(m => `
|
||||
<div class="module-card">
|
||||
<div class="module-name">${m.name}</div>
|
||||
<div class="text-dim" style="font-size: 0.8rem">Created: ${new Date(m.created_at).toLocaleDateString()}</div>
|
||||
moduleListBody.innerHTML = modules.map(m => {
|
||||
const isArchived = !m.is_active;
|
||||
const rowStyle = isArchived ? 'opacity: 0.6; background: rgba(0,0,0,0.2);' : '';
|
||||
|
||||
<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.lob ? `<span class="badge" title="Line of Business">${m.lob}</span>` : ''}
|
||||
${m.job_code ? `<span class="badge" title="Job Code">${m.job_code}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="key-box">
|
||||
<span class="key-text" id="key-${m.id}">••••••••••••••••••••••••••••••••</span>
|
||||
<div class="icon-actions">
|
||||
<button class="icon-btn" onclick="toggleKey(${m.id}, '${m.secret_key}')" title="Show/Hide 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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
${!isArchived ? `
|
||||
<button class="icon-btn" onclick="rotateKey(${m.id})" title="Rotate Key">
|
||||
<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 class="icon-btn" onclick="copyKey('${m.secret_key}')" title="Copy Key">
|
||||
<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>
|
||||
<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"><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 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 class="actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Modal Logic
|
||||
@@ -613,20 +694,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleKey = (id, key) => {
|
||||
const el = document.getElementById(`key-${id}`);
|
||||
const icon = document.getElementById(`eye-icon-${id}`);
|
||||
const isHidden = el.innerText.includes('•');
|
||||
// View Key Modal Logic
|
||||
const viewKeyModal = document.getElementById('view-key-modal');
|
||||
const closeKeyModalBtn = document.getElementById('close-key-modal-btn');
|
||||
const copyModalKeyBtn = document.getElementById('copy-modal-key-btn');
|
||||
const modalKeyDisplay = document.getElementById('modal-key-display');
|
||||
let currentKeyToCopy = '';
|
||||
|
||||
if (isHidden) {
|
||||
el.innerText = key;
|
||||
el.classList.add('visible');
|
||||
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>';
|
||||
} else {
|
||||
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>';
|
||||
}
|
||||
closeKeyModalBtn.onclick = () => viewKeyModal.classList.add('hidden');
|
||||
|
||||
copyModalKeyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(currentKeyToCopy);
|
||||
showToast('Key copied to clipboard!');
|
||||
};
|
||||
|
||||
window.viewKey = (key) => {
|
||||
currentKeyToCopy = key;
|
||||
modalKeyDisplay.innerText = key;
|
||||
viewKeyModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.copyKey = (key) => {
|
||||
@@ -634,23 +719,95 @@
|
||||
showToast('Key copied to clipboard!');
|
||||
};
|
||||
|
||||
window.rotateKey = async (id) => {
|
||||
if (confirm('Are you sure? Previous key will stop working immediately.')) {
|
||||
// Confirmation Modal Logic
|
||||
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');
|
||||
showToast('Key rotated successfully');
|
||||
loadModules();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
window.deleteModule = async (id) => {
|
||||
if (confirm('Delete this module? This cannot be undone.')) {
|
||||
window.archiveModule = (id) => {
|
||||
openConfirmModal(
|
||||
'Archive Module',
|
||||
'Archive this module? It will stop working immediately.',
|
||||
true,
|
||||
async () => {
|
||||
await apiRequest(`/internal/admin/modules/${id}`, 'DELETE');
|
||||
showToast('Module deleted');
|
||||
showToast('Module archived');
|
||||
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;
|
||||
logoutBtn.onclick = logout;
|
||||
|
||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal 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
20
run.py
@@ -6,21 +6,21 @@ from dotenv import load_dotenv
|
||||
|
||||
def setup_environment():
|
||||
"""Ensure dependencies are installed and .env exists."""
|
||||
print("🚀 Initializing Storyline AI Gateway...")
|
||||
print(">> Initializing Storyline AI Gateway...")
|
||||
|
||||
# 1. Check for .env file
|
||||
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.")
|
||||
|
||||
# 2. Check for virtual environment and install dependencies if requested
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--install":
|
||||
print("📦 Installing dependencies from requirements.txt...")
|
||||
print(">> Installing dependencies from requirements.txt...")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
|
||||
print("✅ Dependencies installed successfully.")
|
||||
print("++ Dependencies installed successfully.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error installing dependencies: {e}")
|
||||
print(f"-- Error installing dependencies: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def run_server():
|
||||
@@ -28,16 +28,16 @@ def run_server():
|
||||
load_dotenv()
|
||||
|
||||
# Configuration
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
port = int(os.getenv("PORT", 8000))
|
||||
# On a server, we usually don't want reload=True by default for performance
|
||||
# But for a 'test server', it might be useful.
|
||||
reload = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
print(f"\n🌐 Gateway starting at http://{host}:{port}")
|
||||
print(f"🛠️ Admin Dashboard: http://{host}:{port}/admin")
|
||||
print(f"📚 API Docs: http://{host}:{port}/docs")
|
||||
print(f"🔄 Mode: {'Development (Reload On)' if reload else 'Production (Reload Off)'}\n")
|
||||
print(f"\n>> Gateway starting at http://{host}:{port}")
|
||||
print(f">> Admin Dashboard: http://{host}:{port}/admin")
|
||||
print(f">> API Docs: http://{host}:{port}/docs")
|
||||
print(f">> Mode: {'Development (Reload On)' if reload else 'Production (Reload Off)'}\n")
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
|
||||
30
update_schema.py
Normal file
30
update_schema.py
Normal 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()
|
||||
Reference in New Issue
Block a user