diff --git a/.agent/workflows/setup_local_env.md b/.agent/workflows/setup_local_env.md
new file mode 100644
index 0000000..754cca3
--- /dev/null
+++ b/.agent/workflows/setup_local_env.md
@@ -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
+ ```
diff --git a/.agent/workflows/setup_with_uv.md b/.agent/workflows/setup_with_uv.md
new file mode 100644
index 0000000..0489102
--- /dev/null
+++ b/.agent/workflows/setup_with_uv.md
@@ -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
+ ```
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/admin_backend.py b/app/api/admin_backend.py
index ba8fa34..c69b022 100644
--- a/app/api/admin_backend.py
+++ b/app/api/admin_backend.py
@@ -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")
- db.delete(db_module)
+
+ 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
diff --git a/app/api/deps.py b/app/api/deps.py
index e2e99b5..a24cee6 100644
--- a/app/api/deps.py
+++ b/app/api/deps.py
@@ -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"
+ )
diff --git a/app/api/endpoints/gemini.py b/app/api/endpoints/gemini.py
index 7912b11..ad73666 100644
--- a/app/api/endpoints/gemini.py
+++ b/app/api/endpoints/gemini.py
@@ -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",
diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py
index f261f75..1f213af 100644
--- a/app/api/endpoints/openai.py
+++ b/app/api/endpoints/openai.py
@@ -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",
diff --git a/app/models/module.py b/app/models/module.py
index b92ab4f..25d659b 100644
--- a/app/models/module.py
+++ b/app/models/module.py
@@ -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)
diff --git a/app/static/admin.html b/app/static/admin.html
index c89da7a..26c8ce9 100644
--- a/app/static/admin.html
+++ b/app/static/admin.html
@@ -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);
+ }
+ }
@@ -422,17 +409,36 @@
E-Learning Modules
-
- Modules & Token Usage
+
+
+
+
+
+
+
+
+ Module Name
+ API Key
+ Ingress
+ Egress
+ Total
+ Meta
+ Actions
+