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 NameAPI KeyIngressEgressTotalMetaActions
- + + + + + +
Action successful!