Spaces:
Sleeping
Sleeping
Commit Β·
1ebb69b
0
Parent(s):
Deploy Nancy Relay Gateway
Browse files- .gitignore +7 -0
- Dockerfile +26 -0
- README.md +41 -0
- config.py +148 -0
- core/__init__.py +1 -0
- core/auth.py +122 -0
- core/queue.py +243 -0
- core/redis_client.py +295 -0
- core/router.py +227 -0
- core/sessions.py +246 -0
- core/tools.py +246 -0
- main.py +102 -0
- models/__init__.py +1 -0
- models/openai.py +289 -0
- models/provider.py +156 -0
- models/task.py +201 -0
- requirements.txt +18 -0
- routers/__init__.py +1 -0
- routers/admin.py +831 -0
- routers/api.py +512 -0
- routers/extension.py +150 -0
- routers/health.py +62 -0
- routers/sessions.py +164 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.git/
|
| 5 |
+
.env.*
|
| 6 |
+
.venv/
|
| 7 |
+
venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# HF Spaces requires UID 1000
|
| 4 |
+
RUN useradd -m -u 1000 appuser
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Install dependencies first for layer caching
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 11 |
+
pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Copy application code
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Switch to non-root user
|
| 17 |
+
USER appuser
|
| 18 |
+
|
| 19 |
+
# HF Spaces expects port 7860
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# Health check
|
| 23 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 24 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 25 |
+
|
| 26 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Nancy
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Nancy β Free LLM Router
|
| 13 |
+
|
| 14 |
+
Nancy converts free chatbot web UIs into OpenAI-compatible APIs.
|
| 15 |
+
|
| 16 |
+
## Architecture
|
| 17 |
+
|
| 18 |
+
- **API Layer**: OpenAI-compatible `/v1/chat/completions` and `/v1/models`
|
| 19 |
+
- **Task Queue**: Async task queue bridges API requests to browser extension
|
| 20 |
+
- **Extension Relay**: Chrome extension connects via SSE, executes tasks in real browser tabs
|
| 21 |
+
- **Provider Router**: Circuit breaker + fallback chains across multiple free LLM providers
|
| 22 |
+
|
| 23 |
+
## Usage
|
| 24 |
+
|
| 25 |
+
```python
|
| 26 |
+
from openai import OpenAI
|
| 27 |
+
|
| 28 |
+
client = OpenAI(
|
| 29 |
+
base_url="https://your-space.hf.space/v1",
|
| 30 |
+
api_key="your-nancy-api-key",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
response = client.chat.completions.create(
|
| 34 |
+
model="chatgpt",
|
| 35 |
+
messages=[{"role": "user", "content": "Hello!"}],
|
| 36 |
+
stream=True,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
for chunk in response:
|
| 40 |
+
print(chunk.choices[0].delta.content or "", end="")
|
| 41 |
+
```
|
config.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Configuration Module.
|
| 3 |
+
|
| 4 |
+
All settings are loaded from environment variables with sensible defaults.
|
| 5 |
+
Provider configuration can be supplied as a JSON string via PROVIDERS_CONFIG.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from pydantic import Field, field_validator
|
| 15 |
+
from pydantic_settings import BaseSettings
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("nancy.config")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Settings(BaseSettings):
|
| 21 |
+
"""Application settings sourced from environment variables."""
|
| 22 |
+
|
| 23 |
+
# ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
nancy_api_key: str = Field(
|
| 25 |
+
default="nancy-dev-key",
|
| 26 |
+
description="Bearer token required for /v1/* API endpoints.",
|
| 27 |
+
)
|
| 28 |
+
nancy_ext_secret: str = Field(
|
| 29 |
+
default="nancy-ext-dev-secret",
|
| 30 |
+
description="Bearer token required for /ext/* extension endpoints.",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# ββ Upstash Redis (optional) ββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
upstash_redis_rest_url: str = Field(
|
| 35 |
+
default="",
|
| 36 |
+
description="Upstash Redis REST URL. Leave empty to use in-memory fallback.",
|
| 37 |
+
)
|
| 38 |
+
upstash_redis_rest_token: str = Field(
|
| 39 |
+
default="",
|
| 40 |
+
description="Upstash Redis REST bearer token.",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# ββ Official Paid APIs / Hybrid Keys (optional) βββββββββββββββββββ
|
| 44 |
+
mistral_api_key: str = Field(default="", description="Official Mistral API Key.")
|
| 45 |
+
nvidia_nim_api_key: str = Field(default="", description="Official NVIDIA NIM API Key.")
|
| 46 |
+
deepseek_api_key: str = Field(default="", description="Official DeepSeek API Key.")
|
| 47 |
+
anthropic_api_key: str = Field(default="", description="Official Anthropic/Claude API Key.")
|
| 48 |
+
z_ai_api_key: str = Field(default="", description="Official Z.ai API Key.")
|
| 49 |
+
|
| 50 |
+
# ββ Provider Routing ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
default_provider: str = Field(
|
| 52 |
+
default="chatgpt",
|
| 53 |
+
description="Default provider when the model name is not recognized.",
|
| 54 |
+
)
|
| 55 |
+
fallback_chain: list[str] = Field(
|
| 56 |
+
default=["chatgpt", "gemini", "deepseek", "kimi", "claude", "nim", "zai"],
|
| 57 |
+
description="Ordered list of providers to try on failure.",
|
| 58 |
+
)
|
| 59 |
+
providers_config: dict[str, Any] = Field(
|
| 60 |
+
default_factory=lambda: {
|
| 61 |
+
"chatgpt": {"rpm": 10, "tpm": 40000, "url_pattern": "https://chatgpt.com"},
|
| 62 |
+
"gemini": {"rpm": 15, "tpm": 60000, "url_pattern": "https://gemini.google.com"},
|
| 63 |
+
"deepseek": {"rpm": 10, "tpm": 40000, "url_pattern": "https://chat.deepseek.com"},
|
| 64 |
+
"kimi": {"rpm": 10, "tpm": 40000, "url_pattern": "https://kimi.moonshot.cn"},
|
| 65 |
+
"claude": {"rpm": 5, "tpm": 30000, "url_pattern": "https://claude.ai"},
|
| 66 |
+
"nim": {"rpm": 5, "tpm": 20000, "url_pattern": "https://build.nvidia.com/nim"},
|
| 67 |
+
"zai": {"rpm": 5, "tpm": 20000, "url_pattern": "https://chat.z.ai"},
|
| 68 |
+
},
|
| 69 |
+
description="Per-provider configuration. Supply as JSON string via env var.",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ββ Circuit Breaker βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
cb_failure_threshold: int = Field(
|
| 75 |
+
default=3,
|
| 76 |
+
description="Consecutive failures before tripping the circuit breaker.",
|
| 77 |
+
)
|
| 78 |
+
cb_cooldown_seconds: float = Field(
|
| 79 |
+
default=60.0,
|
| 80 |
+
description="Seconds to wait before retrying a tripped provider.",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# ββ Task Queue ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
task_timeout_seconds: float = Field(
|
| 85 |
+
default=120.0,
|
| 86 |
+
description="Max seconds to wait for extension to complete a task.",
|
| 87 |
+
)
|
| 88 |
+
task_queue_max_size: int = Field(
|
| 89 |
+
default=100,
|
| 90 |
+
description="Maximum number of pending tasks in the queue.",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# ββ Extension βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
ext_heartbeat_timeout_seconds: float = Field(
|
| 95 |
+
default=30.0,
|
| 96 |
+
description="Seconds after last heartbeat before extension is considered offline.",
|
| 97 |
+
)
|
| 98 |
+
ext_sse_keepalive_seconds: float = Field(
|
| 99 |
+
default=15.0,
|
| 100 |
+
description="Interval for SSE keepalive pings to the extension.",
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# ββ Server ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
log_level: str = Field(default="INFO", description="Logging level.")
|
| 105 |
+
cors_origins: list[str] = Field(
|
| 106 |
+
default=["*"],
|
| 107 |
+
description="Allowed CORS origins.",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# ββ Validators ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
@field_validator("providers_config", mode="before")
|
| 112 |
+
@classmethod
|
| 113 |
+
def parse_providers_json(cls, v: Any) -> dict[str, Any]:
|
| 114 |
+
"""Accept a JSON string or dict for providers_config."""
|
| 115 |
+
if isinstance(v, str):
|
| 116 |
+
try:
|
| 117 |
+
return json.loads(v)
|
| 118 |
+
except json.JSONDecodeError as exc:
|
| 119 |
+
logger.error("Invalid PROVIDERS_CONFIG JSON: %s", exc)
|
| 120 |
+
raise ValueError(f"PROVIDERS_CONFIG is not valid JSON: {exc}") from exc
|
| 121 |
+
return v
|
| 122 |
+
|
| 123 |
+
@field_validator("fallback_chain", mode="before")
|
| 124 |
+
@classmethod
|
| 125 |
+
def parse_fallback_chain(cls, v: Any) -> list[str]:
|
| 126 |
+
"""Accept a comma-separated string or list."""
|
| 127 |
+
if isinstance(v, str):
|
| 128 |
+
return [s.strip() for s in v.split(",") if s.strip()]
|
| 129 |
+
return v
|
| 130 |
+
|
| 131 |
+
@field_validator("cors_origins", mode="before")
|
| 132 |
+
@classmethod
|
| 133 |
+
def parse_cors_origins(cls, v: Any) -> list[str]:
|
| 134 |
+
"""Accept a comma-separated string or list."""
|
| 135 |
+
if isinstance(v, str):
|
| 136 |
+
return [s.strip() for s in v.split(",") if s.strip()]
|
| 137 |
+
return v
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def redis_enabled(self) -> bool:
|
| 141 |
+
"""Return True if Upstash Redis is configured."""
|
| 142 |
+
return bool(self.upstash_redis_rest_url and self.upstash_redis_rest_token)
|
| 143 |
+
|
| 144 |
+
model_config = {"env_prefix": "", "case_sensitive": False}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# Module-level singleton
|
| 148 |
+
settings = Settings()
|
core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Nancy core package."""
|
core/auth.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β Authentication & Authorization.
|
| 3 |
+
|
| 4 |
+
Provides FastAPI dependency functions for bearer token validation.
|
| 5 |
+
Two separate tokens are used:
|
| 6 |
+
- ``NANCY_API_KEY`` β for agent-facing ``/v1/*`` endpoints
|
| 7 |
+
- ``NANCY_EXT_SECRET`` β for extension-facing ``/ext/*`` endpoints
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import hashlib
|
| 14 |
+
import time
|
| 15 |
+
import asyncio
|
| 16 |
+
from typing import Annotated
|
| 17 |
+
|
| 18 |
+
from fastapi import Depends, HTTPException, Request, status
|
| 19 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 20 |
+
|
| 21 |
+
from config import settings
|
| 22 |
+
from core.redis_client import redis_client
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("nancy.auth")
|
| 26 |
+
|
| 27 |
+
# Reusable security scheme β auto_error=False lets us return a nicer message
|
| 28 |
+
_bearer_scheme = HTTPBearer(auto_error=False)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _extract_token(
|
| 32 |
+
request: Request,
|
| 33 |
+
credentials: HTTPAuthorizationCredentials | None,
|
| 34 |
+
) -> str:
|
| 35 |
+
"""
|
| 36 |
+
Extract the bearer token from the Authorization header.
|
| 37 |
+
|
| 38 |
+
Falls back to the ``authorization`` query parameter for SSE connections
|
| 39 |
+
where some clients cannot set custom headers.
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
HTTPException(401): If no token is present.
|
| 43 |
+
"""
|
| 44 |
+
if credentials and credentials.credentials:
|
| 45 |
+
return credentials.credentials
|
| 46 |
+
|
| 47 |
+
# Fallback: query param (useful for EventSource which can't set headers)
|
| 48 |
+
query_token = request.query_params.get("authorization") or request.query_params.get("token")
|
| 49 |
+
if query_token:
|
| 50 |
+
# Strip "Bearer " prefix if present
|
| 51 |
+
if query_token.lower().startswith("bearer "):
|
| 52 |
+
return query_token[7:]
|
| 53 |
+
return query_token
|
| 54 |
+
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 57 |
+
detail="Missing authorization header. Provide 'Authorization: Bearer <token>'.",
|
| 58 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 59 |
+
)
|
| 60 |
+
async def require_api_key(
|
| 61 |
+
request: Request,
|
| 62 |
+
credentials: Annotated[
|
| 63 |
+
HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)
|
| 64 |
+
] = None,
|
| 65 |
+
) -> str:
|
| 66 |
+
"""
|
| 67 |
+
FastAPI dependency: validates the bearer token against either NANCY_API_KEY
|
| 68 |
+
or a SHA-256 hashed token cached dynamically in Upstash Redis.
|
| 69 |
+
|
| 70 |
+
Returns the validated token string on success.
|
| 71 |
+
"""
|
| 72 |
+
token = _extract_token(request, credentials)
|
| 73 |
+
|
| 74 |
+
# 1. Master Key Local Bypass
|
| 75 |
+
if token == settings.nancy_api_key:
|
| 76 |
+
return token
|
| 77 |
+
|
| 78 |
+
# 2. Dynamic Redis Hashed Key validation
|
| 79 |
+
hashed = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
| 80 |
+
try:
|
| 81 |
+
key_meta = await redis_client.get_json(f"nancy:api_keys:{hashed}")
|
| 82 |
+
if key_meta:
|
| 83 |
+
# Asynchronously update key metadata (fire-and-forget to keep requests fast)
|
| 84 |
+
key_meta["last_used"] = int(time.time())
|
| 85 |
+
key_meta["request_count"] = key_meta.get("request_count", 0) + 1
|
| 86 |
+
asyncio.create_task(redis_client.set_json(f"nancy:api_keys:{hashed}", key_meta))
|
| 87 |
+
return token
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
logger.error("Error validating dynamic hashed token in Redis: %s", exc)
|
| 90 |
+
|
| 91 |
+
logger.warning("Invalid API key attempt from %s", request.client.host if request.client else "unknown")
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 94 |
+
detail="Invalid API key.",
|
| 95 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def require_ext_secret(
|
| 101 |
+
request: Request,
|
| 102 |
+
credentials: Annotated[
|
| 103 |
+
HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)
|
| 104 |
+
] = None,
|
| 105 |
+
) -> str:
|
| 106 |
+
"""
|
| 107 |
+
FastAPI dependency: validates the bearer token against ``NANCY_EXT_SECRET``.
|
| 108 |
+
|
| 109 |
+
Used for all ``/ext/*`` endpoints.
|
| 110 |
+
"""
|
| 111 |
+
token = _extract_token(request, credentials)
|
| 112 |
+
if token != settings.nancy_ext_secret:
|
| 113 |
+
logger.warning(
|
| 114 |
+
"Invalid extension secret attempt from %s",
|
| 115 |
+
request.client.host if request.client else "unknown",
|
| 116 |
+
)
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 119 |
+
detail="Invalid extension secret.",
|
| 120 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 121 |
+
)
|
| 122 |
+
return token
|
core/queue.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β Task Queue.
|
| 3 |
+
|
| 4 |
+
Manages the lifecycle of tasks using ``asyncio.Queue`` for the pending
|
| 5 |
+
work queue and a dict of ``TaskHandle`` objects for in-flight coordination.
|
| 6 |
+
|
| 7 |
+
The queue bridges two sides:
|
| 8 |
+
- **API side** (producer): creates a task, enqueues it, waits for chunks.
|
| 9 |
+
- **Extension side** (consumer): dequeues tasks via SSE, pushes response chunks.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import logging
|
| 16 |
+
import time
|
| 17 |
+
from typing import AsyncIterator
|
| 18 |
+
|
| 19 |
+
from config import settings
|
| 20 |
+
from models.task import Task, TaskHandle, TaskStatus
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("nancy.queue")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TaskQueue:
|
| 26 |
+
"""
|
| 27 |
+
Central task queue and handle registry.
|
| 28 |
+
|
| 29 |
+
This is a singleton that coordinates between the API router
|
| 30 |
+
(which creates tasks) and the extension router (which fulfills them).
|
| 31 |
+
|
| 32 |
+
Attributes:
|
| 33 |
+
_pending: asyncio.Queue of Task objects waiting for an extension.
|
| 34 |
+
_handles: dict mapping ``task_id`` β ``TaskHandle`` for in-flight tasks.
|
| 35 |
+
_history: bounded list of recently completed task summaries.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(self, max_size: int | None = None) -> None:
|
| 39 |
+
self._max_size = max_size or settings.task_queue_max_size
|
| 40 |
+
self._pending: asyncio.Queue[Task] = asyncio.Queue(maxsize=self._max_size)
|
| 41 |
+
self._handles: dict[str, TaskHandle] = {}
|
| 42 |
+
self._history: list[dict] = []
|
| 43 |
+
self._max_history = 100
|
| 44 |
+
# Event fired whenever a new task is enqueued β used to wake the
|
| 45 |
+
# extension SSE stream.
|
| 46 |
+
self._new_task_event = asyncio.Event()
|
| 47 |
+
|
| 48 |
+
# ββ API-side operations βββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
async def submit_task(self, task: Task) -> TaskHandle:
|
| 51 |
+
"""
|
| 52 |
+
Submit a new task and return its handle.
|
| 53 |
+
|
| 54 |
+
The handle's ``chunk_queue`` and ``done_event`` are used by the
|
| 55 |
+
API router to stream chunks back to the caller.
|
| 56 |
+
|
| 57 |
+
Raises:
|
| 58 |
+
asyncio.QueueFull: If the pending queue is at capacity.
|
| 59 |
+
"""
|
| 60 |
+
handle = TaskHandle(task)
|
| 61 |
+
self._handles[task.task_id] = handle
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
self._pending.put_nowait(task)
|
| 65 |
+
except asyncio.QueueFull:
|
| 66 |
+
# Clean up the handle
|
| 67 |
+
self._handles.pop(task.task_id, None)
|
| 68 |
+
logger.error("Task queue full β rejecting task %s", task.task_id)
|
| 69 |
+
raise
|
| 70 |
+
|
| 71 |
+
self._new_task_event.set()
|
| 72 |
+
logger.info(
|
| 73 |
+
"Task %s submitted (provider=%s, model=%s, queue_size=%d)",
|
| 74 |
+
task.task_id,
|
| 75 |
+
task.provider,
|
| 76 |
+
task.model,
|
| 77 |
+
self._pending.qsize(),
|
| 78 |
+
)
|
| 79 |
+
return handle
|
| 80 |
+
|
| 81 |
+
def get_handle(self, task_id: str) -> TaskHandle | None:
|
| 82 |
+
"""Retrieve a handle by task ID, or None if not found."""
|
| 83 |
+
return self._handles.get(task_id)
|
| 84 |
+
|
| 85 |
+
async def wait_for_completion(
|
| 86 |
+
self,
|
| 87 |
+
handle: TaskHandle,
|
| 88 |
+
timeout: float | None = None,
|
| 89 |
+
) -> None:
|
| 90 |
+
"""
|
| 91 |
+
Block until the task is done or timeout expires.
|
| 92 |
+
|
| 93 |
+
This is used for *non-streaming* requests that need the full response.
|
| 94 |
+
|
| 95 |
+
Raises:
|
| 96 |
+
asyncio.TimeoutError: If the task does not complete in time.
|
| 97 |
+
"""
|
| 98 |
+
timeout = timeout or settings.task_timeout_seconds
|
| 99 |
+
try:
|
| 100 |
+
await asyncio.wait_for(handle.done_event.wait(), timeout=timeout)
|
| 101 |
+
except asyncio.TimeoutError:
|
| 102 |
+
handle.task.status = TaskStatus.TIMED_OUT
|
| 103 |
+
handle.task.error = f"Task timed out after {timeout}s"
|
| 104 |
+
handle.finish(error=handle.task.error)
|
| 105 |
+
raise
|
| 106 |
+
|
| 107 |
+
async def stream_chunks(
|
| 108 |
+
self,
|
| 109 |
+
handle: TaskHandle,
|
| 110 |
+
timeout: float | None = None,
|
| 111 |
+
) -> AsyncIterator[str]:
|
| 112 |
+
"""
|
| 113 |
+
Async generator that yields text chunks from the extension.
|
| 114 |
+
|
| 115 |
+
Yields chunks until a ``None`` sentinel is received (end of stream)
|
| 116 |
+
or the timeout expires.
|
| 117 |
+
"""
|
| 118 |
+
timeout = timeout or settings.task_timeout_seconds
|
| 119 |
+
deadline = time.time() + timeout
|
| 120 |
+
|
| 121 |
+
while True:
|
| 122 |
+
remaining = deadline - time.time()
|
| 123 |
+
if remaining <= 0:
|
| 124 |
+
handle.task.status = TaskStatus.TIMED_OUT
|
| 125 |
+
handle.task.error = "Streaming timed out"
|
| 126 |
+
logger.warning("Task %s stream timed out", handle.task_id)
|
| 127 |
+
break
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
chunk = await asyncio.wait_for(
|
| 131 |
+
handle.chunk_queue.get(),
|
| 132 |
+
timeout=min(remaining, 30.0),
|
| 133 |
+
)
|
| 134 |
+
except asyncio.TimeoutError:
|
| 135 |
+
# Check if there's still time left
|
| 136 |
+
if time.time() >= deadline:
|
| 137 |
+
handle.task.status = TaskStatus.TIMED_OUT
|
| 138 |
+
handle.task.error = "Streaming timed out"
|
| 139 |
+
logger.warning("Task %s stream timed out", handle.task_id)
|
| 140 |
+
break
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
if chunk is None:
|
| 144 |
+
# End-of-stream sentinel
|
| 145 |
+
break
|
| 146 |
+
|
| 147 |
+
yield chunk
|
| 148 |
+
|
| 149 |
+
# ββ Extension-side operations βββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
|
| 151 |
+
async def dequeue_task(self, timeout: float = 30.0) -> Task | None:
|
| 152 |
+
"""
|
| 153 |
+
Dequeue the next pending task.
|
| 154 |
+
|
| 155 |
+
Returns ``None`` if no task is available within ``timeout`` seconds.
|
| 156 |
+
Used by the extension SSE stream.
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
task = await asyncio.wait_for(self._pending.get(), timeout=timeout)
|
| 160 |
+
task.status = TaskStatus.ASSIGNED
|
| 161 |
+
task.assigned_at = time.time()
|
| 162 |
+
logger.info("Task %s dequeued (provider=%s)", task.task_id, task.provider)
|
| 163 |
+
return task
|
| 164 |
+
except asyncio.TimeoutError:
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
def push_chunk(self, task_id: str, chunk: str) -> bool:
|
| 168 |
+
"""
|
| 169 |
+
Push a response chunk for a task. Returns False if task not found.
|
| 170 |
+
"""
|
| 171 |
+
handle = self._handles.get(task_id)
|
| 172 |
+
if not handle:
|
| 173 |
+
logger.warning("Chunk received for unknown task %s", task_id)
|
| 174 |
+
return False
|
| 175 |
+
if handle.task.status == TaskStatus.ASSIGNED:
|
| 176 |
+
handle.task.status = TaskStatus.STREAMING
|
| 177 |
+
handle.push_chunk(chunk)
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
def complete_task(self, task_id: str, error: str | None = None) -> bool:
|
| 181 |
+
"""
|
| 182 |
+
Mark a task as complete. Returns False if task not found.
|
| 183 |
+
"""
|
| 184 |
+
handle = self._handles.get(task_id)
|
| 185 |
+
if not handle:
|
| 186 |
+
logger.warning("Completion signal for unknown task %s", task_id)
|
| 187 |
+
return False
|
| 188 |
+
handle.finish(error=error)
|
| 189 |
+
|
| 190 |
+
# Archive to history
|
| 191 |
+
self._history.append(handle.task.to_status_dict())
|
| 192 |
+
if len(self._history) > self._max_history:
|
| 193 |
+
self._history = self._history[-self._max_history:]
|
| 194 |
+
|
| 195 |
+
logger.info(
|
| 196 |
+
"Task %s completed (status=%s, error=%s)",
|
| 197 |
+
task_id,
|
| 198 |
+
handle.task.status.value,
|
| 199 |
+
error,
|
| 200 |
+
)
|
| 201 |
+
return True
|
| 202 |
+
|
| 203 |
+
def cleanup_task(self, task_id: str) -> None:
|
| 204 |
+
"""Remove a task handle from the registry."""
|
| 205 |
+
self._handles.pop(task_id, None)
|
| 206 |
+
|
| 207 |
+
# ββ Observability βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
|
| 209 |
+
@property
|
| 210 |
+
def pending_count(self) -> int:
|
| 211 |
+
"""Number of tasks waiting in the queue."""
|
| 212 |
+
return self._pending.qsize()
|
| 213 |
+
|
| 214 |
+
@property
|
| 215 |
+
def active_count(self) -> int:
|
| 216 |
+
"""Number of in-flight task handles."""
|
| 217 |
+
return len(self._handles)
|
| 218 |
+
|
| 219 |
+
@property
|
| 220 |
+
def new_task_event(self) -> asyncio.Event:
|
| 221 |
+
"""Event that fires when a new task is enqueued."""
|
| 222 |
+
return self._new_task_event
|
| 223 |
+
|
| 224 |
+
def get_status(self) -> dict:
|
| 225 |
+
"""Return queue status for health endpoints."""
|
| 226 |
+
return {
|
| 227 |
+
"pending": self.pending_count,
|
| 228 |
+
"active": self.active_count,
|
| 229 |
+
"max_size": self._max_size,
|
| 230 |
+
"recent_history": len(self._history),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
def get_active_tasks(self) -> list[dict]:
|
| 234 |
+
"""Return status dicts for all active tasks."""
|
| 235 |
+
return [h.task.to_status_dict() for h in self._handles.values()]
|
| 236 |
+
|
| 237 |
+
def get_history(self, limit: int = 20) -> list[dict]:
|
| 238 |
+
"""Return recent task history."""
|
| 239 |
+
return self._history[-limit:]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# Module-level singleton
|
| 243 |
+
task_queue = TaskQueue()
|
core/redis_client.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β Upstash Redis REST API client.
|
| 3 |
+
|
| 4 |
+
Uses ``httpx`` to communicate with Upstash Redis via its REST interface.
|
| 5 |
+
All operations are optional β if Redis is not configured, methods are
|
| 6 |
+
no-ops that return sensible defaults.
|
| 7 |
+
|
| 8 |
+
This allows the HF Space to work without any external dependencies while
|
| 9 |
+
supporting persistence when Upstash credentials are provided.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import httpx
|
| 19 |
+
|
| 20 |
+
from config import settings
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("nancy.redis")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class RedisClient:
|
| 26 |
+
"""
|
| 27 |
+
Async client for the Upstash Redis REST API.
|
| 28 |
+
|
| 29 |
+
All public methods are safe to call even when Redis is not configured β
|
| 30 |
+
they will log a debug message and return ``None`` / empty defaults.
|
| 31 |
+
|
| 32 |
+
Usage::
|
| 33 |
+
|
| 34 |
+
redis = RedisClient()
|
| 35 |
+
await redis.set("key", "value", ex=300)
|
| 36 |
+
val = await redis.get("key")
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(self) -> None:
|
| 40 |
+
self._enabled = settings.redis_enabled
|
| 41 |
+
self._base_url = settings.upstash_redis_rest_url.rstrip("/")
|
| 42 |
+
self._token = settings.upstash_redis_rest_token
|
| 43 |
+
self._client: httpx.AsyncClient | None = None
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def is_enabled(self) -> bool:
|
| 47 |
+
"""Return True if Redis is configured and the client is initialized."""
|
| 48 |
+
return self._enabled and self._client is not None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def startup(self) -> None:
|
| 52 |
+
"""Initialize the HTTP client. Call during app startup."""
|
| 53 |
+
if not self._enabled:
|
| 54 |
+
logger.info("Redis not configured β using in-memory fallbacks.")
|
| 55 |
+
return
|
| 56 |
+
import base64
|
| 57 |
+
import os
|
| 58 |
+
|
| 59 |
+
# Determine authentication headers (Upstash Bearer vs Self-hosted Webdis Basic Auth)
|
| 60 |
+
redis_secret = os.getenv("NANCY_REDIS_SECRET", "")
|
| 61 |
+
if self._token:
|
| 62 |
+
headers = {"Authorization": f"Bearer {self._token}"}
|
| 63 |
+
logger.info("Configuring REST client using Upstash Bearer token.")
|
| 64 |
+
elif redis_secret:
|
| 65 |
+
auth_str = f"nancy_admin:{redis_secret}"
|
| 66 |
+
b64_auth = base64.b64encode(auth_str.encode("utf-8")).decode("utf-8")
|
| 67 |
+
headers = {"Authorization": f"Basic {b64_auth}"}
|
| 68 |
+
logger.info("Configuring REST client using Self-Hosted Webdis Basic Auth.")
|
| 69 |
+
else:
|
| 70 |
+
headers = {}
|
| 71 |
+
logger.warning("No authentication credentials found for REST client.")
|
| 72 |
+
|
| 73 |
+
self._client = httpx.AsyncClient(
|
| 74 |
+
base_url=self._base_url,
|
| 75 |
+
headers=headers,
|
| 76 |
+
timeout=httpx.Timeout(10.0, connect=5.0),
|
| 77 |
+
)
|
| 78 |
+
# Verify connectivity
|
| 79 |
+
try:
|
| 80 |
+
resp = await self._client.post("/", json=["PING"])
|
| 81 |
+
resp.raise_for_status()
|
| 82 |
+
logger.info("Redis connected: %s", resp.json())
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
logger.warning("Redis PING failed (non-fatal): %s", exc)
|
| 85 |
+
|
| 86 |
+
async def shutdown(self) -> None:
|
| 87 |
+
"""Close the HTTP client. Call during app shutdown."""
|
| 88 |
+
if self._client:
|
| 89 |
+
await self._client.aclose()
|
| 90 |
+
self._client = None
|
| 91 |
+
|
| 92 |
+
# ββ Low-level command execution βββββββββββββββββββββββββββββββββββ
|
| 93 |
+
|
| 94 |
+
async def _execute(self, *args: str) -> Any:
|
| 95 |
+
"""
|
| 96 |
+
Execute a raw Redis command via the REST API.
|
| 97 |
+
|
| 98 |
+
Returns the ``result`` field from the Upstash response, or ``None``
|
| 99 |
+
on error / when Redis is disabled.
|
| 100 |
+
"""
|
| 101 |
+
if not self._enabled or not self._client:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
import os
|
| 105 |
+
is_webdis = os.getenv("NANCY_REDIS_SECRET", "") and not settings.upstash_redis_rest_token
|
| 106 |
+
|
| 107 |
+
if not is_webdis:
|
| 108 |
+
# Traditional Upstash REST API call
|
| 109 |
+
try:
|
| 110 |
+
resp = await self._client.post("/", json=list(args))
|
| 111 |
+
resp.raise_for_status()
|
| 112 |
+
data = resp.json()
|
| 113 |
+
return data.get("result")
|
| 114 |
+
except httpx.HTTPStatusError as exc:
|
| 115 |
+
logger.error("Redis HTTP error: %s %s", exc.response.status_code, exc.response.text)
|
| 116 |
+
return None
|
| 117 |
+
except Exception as exc:
|
| 118 |
+
logger.error("Redis error: %s", exc)
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
# Webdis REST API Translation logic
|
| 122 |
+
try:
|
| 123 |
+
if not args:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
cmd = args[0].upper()
|
| 127 |
+
|
| 128 |
+
# Special case: SET with or without EX
|
| 129 |
+
if cmd == "SET":
|
| 130 |
+
key = args[1]
|
| 131 |
+
value = args[2]
|
| 132 |
+
ex = None
|
| 133 |
+
if len(args) > 4 and args[3].upper() == "EX":
|
| 134 |
+
ex = args[4]
|
| 135 |
+
|
| 136 |
+
# Use PUT to pass large/complex value safely in the body
|
| 137 |
+
resp = await self._client.put(f"/SET/{key}", content=value)
|
| 138 |
+
resp.raise_for_status()
|
| 139 |
+
|
| 140 |
+
if ex is not None:
|
| 141 |
+
# Set expire separately
|
| 142 |
+
exp_resp = await self._client.post(f"/EXPIRE/{key}/{ex}")
|
| 143 |
+
exp_resp.raise_for_status()
|
| 144 |
+
|
| 145 |
+
return "OK"
|
| 146 |
+
|
| 147 |
+
# Special case: HSET
|
| 148 |
+
elif cmd == "HSET":
|
| 149 |
+
key = args[1]
|
| 150 |
+
field = args[2]
|
| 151 |
+
value = args[3]
|
| 152 |
+
import urllib.parse
|
| 153 |
+
safe_field = urllib.parse.quote(field, safe="")
|
| 154 |
+
resp = await self._client.put(f"/HSET/{key}/{safe_field}", content=value)
|
| 155 |
+
resp.raise_for_status()
|
| 156 |
+
return 1
|
| 157 |
+
|
| 158 |
+
# Special case: LPUSH / RPUSH
|
| 159 |
+
elif cmd in ("LPUSH", "RPUSH"):
|
| 160 |
+
key = args[1]
|
| 161 |
+
value = args[2]
|
| 162 |
+
resp = await self._client.put(f"/{cmd}/{key}", content=value)
|
| 163 |
+
resp.raise_for_status()
|
| 164 |
+
data = resp.json()
|
| 165 |
+
return data.get(cmd)
|
| 166 |
+
|
| 167 |
+
# Special case: SADD / SREM / SISMEMBER
|
| 168 |
+
elif cmd in ("SADD", "SREM", "SISMEMBER"):
|
| 169 |
+
key = args[1]
|
| 170 |
+
value = args[2]
|
| 171 |
+
resp = await self._client.put(f"/{cmd}/{key}", content=value)
|
| 172 |
+
resp.raise_for_status()
|
| 173 |
+
data = resp.json()
|
| 174 |
+
res = data.get(cmd)
|
| 175 |
+
if res is None:
|
| 176 |
+
res = data.get(cmd.lower())
|
| 177 |
+
return res
|
| 178 |
+
|
| 179 |
+
else:
|
| 180 |
+
# Fallback for standard commands: urlencode arguments in the path
|
| 181 |
+
import urllib.parse
|
| 182 |
+
encoded_args = [urllib.parse.quote(str(arg), safe="") for arg in args[1:]]
|
| 183 |
+
if encoded_args:
|
| 184 |
+
path = f"/{cmd}/" + "/".join(encoded_args)
|
| 185 |
+
else:
|
| 186 |
+
path = f"/{cmd}"
|
| 187 |
+
|
| 188 |
+
# Execute via GET
|
| 189 |
+
resp = await self._client.get(path)
|
| 190 |
+
resp.raise_for_status()
|
| 191 |
+
data = resp.json()
|
| 192 |
+
|
| 193 |
+
res = data.get(cmd)
|
| 194 |
+
if res is None:
|
| 195 |
+
res = data.get(cmd.lower())
|
| 196 |
+
return res
|
| 197 |
+
|
| 198 |
+
except httpx.HTTPStatusError as exc:
|
| 199 |
+
logger.error("Webdis Redis HTTP error: %s %s", exc.response.status_code, exc.response.text)
|
| 200 |
+
return None
|
| 201 |
+
except Exception as exc:
|
| 202 |
+
logger.error("Webdis Redis error: %s", exc)
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
# ββ High-level operations βββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
|
| 207 |
+
async def get(self, key: str) -> str | None:
|
| 208 |
+
"""Get a string value by key."""
|
| 209 |
+
return await self._execute("GET", key)
|
| 210 |
+
|
| 211 |
+
async def set(
|
| 212 |
+
self,
|
| 213 |
+
key: str,
|
| 214 |
+
value: str,
|
| 215 |
+
ex: int | None = None,
|
| 216 |
+
) -> bool:
|
| 217 |
+
"""
|
| 218 |
+
Set a string value, optionally with expiration in seconds.
|
| 219 |
+
|
| 220 |
+
Returns True on success.
|
| 221 |
+
"""
|
| 222 |
+
if ex is not None:
|
| 223 |
+
result = await self._execute("SET", key, value, "EX", str(ex))
|
| 224 |
+
else:
|
| 225 |
+
result = await self._execute("SET", key, value)
|
| 226 |
+
return result == "OK"
|
| 227 |
+
|
| 228 |
+
async def delete(self, key: str) -> bool:
|
| 229 |
+
"""Delete a key. Returns True if the key existed."""
|
| 230 |
+
result = await self._execute("DEL", key)
|
| 231 |
+
return result is not None and int(result) > 0
|
| 232 |
+
|
| 233 |
+
async def incr(self, key: str) -> int | None:
|
| 234 |
+
"""Increment an integer key. Returns the new value."""
|
| 235 |
+
result = await self._execute("INCR", key)
|
| 236 |
+
return int(result) if result is not None else None
|
| 237 |
+
|
| 238 |
+
async def expire(self, key: str, seconds: int) -> bool:
|
| 239 |
+
"""Set expiration on an existing key."""
|
| 240 |
+
result = await self._execute("EXPIRE", key, str(seconds))
|
| 241 |
+
return result is not None and int(result) == 1
|
| 242 |
+
|
| 243 |
+
async def lpush(self, key: str, value: str) -> int | None:
|
| 244 |
+
"""Push a value to the head of a list."""
|
| 245 |
+
result = await self._execute("LPUSH", key, value)
|
| 246 |
+
return int(result) if result is not None else None
|
| 247 |
+
|
| 248 |
+
async def lrange(self, key: str, start: int, stop: int) -> list[str]:
|
| 249 |
+
"""Return a range of elements from a list."""
|
| 250 |
+
result = await self._execute("LRANGE", key, str(start), str(stop))
|
| 251 |
+
return result if isinstance(result, list) else []
|
| 252 |
+
|
| 253 |
+
async def hset(self, key: str, field: str, value: str) -> bool:
|
| 254 |
+
"""Set a hash field."""
|
| 255 |
+
result = await self._execute("HSET", key, field, value)
|
| 256 |
+
return result is not None
|
| 257 |
+
|
| 258 |
+
async def hget(self, key: str, field: str) -> str | None:
|
| 259 |
+
"""Get a hash field value."""
|
| 260 |
+
return await self._execute("HGET", key, field)
|
| 261 |
+
|
| 262 |
+
async def hgetall(self, key: str) -> dict[str, str]:
|
| 263 |
+
"""Get all fields and values in a hash."""
|
| 264 |
+
result = await self._execute("HGETALL", key)
|
| 265 |
+
if not result or not isinstance(result, list):
|
| 266 |
+
return {}
|
| 267 |
+
# Upstash returns [field1, val1, field2, val2, ...]
|
| 268 |
+
it = iter(result)
|
| 269 |
+
return dict(zip(it, it))
|
| 270 |
+
|
| 271 |
+
# ββ JSON helpers ββββββββββββββββββοΏ½οΏ½βββββββββββββββββββββββββββββββ
|
| 272 |
+
|
| 273 |
+
async def set_json(
|
| 274 |
+
self,
|
| 275 |
+
key: str,
|
| 276 |
+
value: Any,
|
| 277 |
+
ex: int | None = None,
|
| 278 |
+
) -> bool:
|
| 279 |
+
"""Serialize ``value`` as JSON and store it."""
|
| 280 |
+
return await self.set(key, json.dumps(value, default=str), ex=ex)
|
| 281 |
+
|
| 282 |
+
async def get_json(self, key: str) -> Any | None:
|
| 283 |
+
"""Retrieve and deserialize a JSON value."""
|
| 284 |
+
raw = await self.get(key)
|
| 285 |
+
if raw is None:
|
| 286 |
+
return None
|
| 287 |
+
try:
|
| 288 |
+
return json.loads(raw)
|
| 289 |
+
except json.JSONDecodeError:
|
| 290 |
+
logger.warning("Failed to parse JSON for key '%s'", key)
|
| 291 |
+
return None
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# Module-level singleton
|
| 295 |
+
redis_client = RedisClient()
|
core/router.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β Provider Router.
|
| 3 |
+
|
| 4 |
+
Responsible for selecting which provider handles a given request.
|
| 5 |
+
Implements:
|
| 6 |
+
- Circuit breaker (trip after N consecutive failures, cooldown period)
|
| 7 |
+
- RPM rate limiting (sliding window per provider)
|
| 8 |
+
- Fallback chains (try next provider when current is unavailable)
|
| 9 |
+
- Model β provider name resolution
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from config import settings
|
| 18 |
+
from models.provider import CircuitState, ProviderConfig, ProviderState
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("nancy.router")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ββ Model-to-Provider mapping ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
|
| 25 |
+
# Maps model name aliases to canonical provider names.
|
| 26 |
+
# The extension uses the provider name to know which chatbot tab to target.
|
| 27 |
+
MODEL_TO_PROVIDER: dict[str, str] = {
|
| 28 |
+
# ChatGPT
|
| 29 |
+
"chatgpt": "chatgpt",
|
| 30 |
+
"gpt-4": "chatgpt",
|
| 31 |
+
"gpt-4o": "chatgpt",
|
| 32 |
+
"gpt-4o-mini": "chatgpt",
|
| 33 |
+
"gpt-3.5-turbo": "chatgpt",
|
| 34 |
+
# Gemini
|
| 35 |
+
"gemini": "gemini",
|
| 36 |
+
"gemini-pro": "gemini",
|
| 37 |
+
"gemini-2.0-flash": "gemini",
|
| 38 |
+
"gemini-2.5-pro": "gemini",
|
| 39 |
+
# DeepSeek
|
| 40 |
+
"deepseek": "deepseek",
|
| 41 |
+
"deepseek-chat": "deepseek",
|
| 42 |
+
"deepseek-r1": "deepseek",
|
| 43 |
+
# Kimi
|
| 44 |
+
"kimi": "kimi",
|
| 45 |
+
"moonshot": "kimi",
|
| 46 |
+
# Official Paid API / Hybrid models
|
| 47 |
+
"mistral-large": "api-mistral",
|
| 48 |
+
"nvidia-llama3": "api-nvidia-nim",
|
| 49 |
+
"deepseek-api": "api-deepseek",
|
| 50 |
+
"claude-api": "api-anthropic",
|
| 51 |
+
"z-ai-api": "api-z-ai",
|
| 52 |
+
# Browser relay: NIM Portal (free playground)
|
| 53 |
+
"nim": "nim",
|
| 54 |
+
"nim-llama3": "nim",
|
| 55 |
+
"nim-mistral": "nim",
|
| 56 |
+
# Browser relay: z.ai portal
|
| 57 |
+
"zai": "zai",
|
| 58 |
+
"z-ai": "zai",
|
| 59 |
+
# Claude (browser portal)
|
| 60 |
+
"claude": "claude",
|
| 61 |
+
"claude-3": "claude",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class ProviderRouter:
|
| 66 |
+
"""
|
| 67 |
+
Selects providers for incoming requests with circuit breaking,
|
| 68 |
+
rate limiting, and fallback chain support.
|
| 69 |
+
|
| 70 |
+
Usage::
|
| 71 |
+
|
| 72 |
+
router = ProviderRouter()
|
| 73 |
+
provider = router.resolve("gpt-4o") # β "chatgpt"
|
| 74 |
+
available = router.select_provider("chatgpt") # checks CB + RPM
|
| 75 |
+
router.record_success("chatgpt")
|
| 76 |
+
router.record_failure("chatgpt")
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
def __init__(self) -> None:
|
| 80 |
+
self._states: dict[str, ProviderState] = {}
|
| 81 |
+
self._initialize_providers()
|
| 82 |
+
|
| 83 |
+
def _initialize_providers(self) -> None:
|
| 84 |
+
"""Build ProviderState objects from configuration."""
|
| 85 |
+
for name, raw_config in settings.providers_config.items():
|
| 86 |
+
config = ProviderConfig(**raw_config) if isinstance(raw_config, dict) else ProviderConfig()
|
| 87 |
+
self._states[name] = ProviderState(name, config)
|
| 88 |
+
logger.info(
|
| 89 |
+
"Provider '%s' initialized (rpm=%d, tpm=%d)",
|
| 90 |
+
name,
|
| 91 |
+
config.rpm,
|
| 92 |
+
config.tpm,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Ensure all fallback chain providers exist
|
| 96 |
+
for name in settings.fallback_chain:
|
| 97 |
+
if name not in self._states:
|
| 98 |
+
self._states[name] = ProviderState(name, ProviderConfig())
|
| 99 |
+
logger.info("Provider '%s' added from fallback chain with defaults", name)
|
| 100 |
+
|
| 101 |
+
# ββ Resolution ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
|
| 103 |
+
def resolve(self, model: str) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Resolve a model name to a canonical provider name.
|
| 106 |
+
|
| 107 |
+
Falls back to ``settings.default_provider`` if the model is unknown.
|
| 108 |
+
"""
|
| 109 |
+
provider = MODEL_TO_PROVIDER.get(model.lower(), model.lower())
|
| 110 |
+
# If the resolved name is a known provider, use it
|
| 111 |
+
if provider in self._states:
|
| 112 |
+
return provider
|
| 113 |
+
# Otherwise fall back to default
|
| 114 |
+
logger.debug(
|
| 115 |
+
"Unknown model '%s' β defaulting to '%s'",
|
| 116 |
+
model,
|
| 117 |
+
settings.default_provider,
|
| 118 |
+
)
|
| 119 |
+
return settings.default_provider
|
| 120 |
+
|
| 121 |
+
# ββ Provider Selection with Circuit Breaker + Rate Limit ββββββββββ
|
| 122 |
+
|
| 123 |
+
def select_provider(
|
| 124 |
+
self,
|
| 125 |
+
preferred: str,
|
| 126 |
+
exclude: set[str] | None = None,
|
| 127 |
+
) -> str | None:
|
| 128 |
+
"""
|
| 129 |
+
Select the best available provider.
|
| 130 |
+
|
| 131 |
+
1. Try the preferred provider first.
|
| 132 |
+
2. If it's unavailable (circuit open, rate limited), walk the fallback chain.
|
| 133 |
+
3. Return ``None`` if no provider is available.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
preferred: The preferred provider name.
|
| 137 |
+
exclude: Set of provider names to skip (already tried and failed).
|
| 138 |
+
"""
|
| 139 |
+
exclude = exclude or set()
|
| 140 |
+
candidates = [preferred] + [
|
| 141 |
+
p for p in settings.fallback_chain if p != preferred
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
for name in candidates:
|
| 145 |
+
if name in exclude:
|
| 146 |
+
continue
|
| 147 |
+
|
| 148 |
+
state = self._states.get(name)
|
| 149 |
+
if not state:
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
# Check circuit breaker
|
| 153 |
+
if not state.should_allow_request(
|
| 154 |
+
settings.cb_failure_threshold,
|
| 155 |
+
settings.cb_cooldown_seconds,
|
| 156 |
+
):
|
| 157 |
+
logger.debug("Provider '%s' circuit is OPEN β skipping", name)
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
# Check rate limit
|
| 161 |
+
if not state.check_rate_limit():
|
| 162 |
+
logger.debug("Provider '%s' rate limited β skipping", name)
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
# Record the request
|
| 166 |
+
state.record_request()
|
| 167 |
+
logger.info("Selected provider: '%s'", name)
|
| 168 |
+
return name
|
| 169 |
+
|
| 170 |
+
logger.error(
|
| 171 |
+
"No available provider (preferred=%s, exclude=%s)",
|
| 172 |
+
preferred,
|
| 173 |
+
exclude,
|
| 174 |
+
)
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
# ββ Feedback ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 178 |
+
|
| 179 |
+
def record_success(self, provider: str) -> None:
|
| 180 |
+
"""Record a successful completion for the given provider."""
|
| 181 |
+
state = self._states.get(provider)
|
| 182 |
+
if state:
|
| 183 |
+
state.record_success()
|
| 184 |
+
logger.debug("Provider '%s' success recorded", provider)
|
| 185 |
+
|
| 186 |
+
def record_failure(self, provider: str) -> None:
|
| 187 |
+
"""Record a failure for the given provider (may trip circuit)."""
|
| 188 |
+
state = self._states.get(provider)
|
| 189 |
+
if state:
|
| 190 |
+
state.record_failure(settings.cb_failure_threshold)
|
| 191 |
+
logger.warning(
|
| 192 |
+
"Provider '%s' failure recorded (consecutive=%d, circuit=%s)",
|
| 193 |
+
provider,
|
| 194 |
+
state.consecutive_failures,
|
| 195 |
+
state.circuit_state.value,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# ββ Observability βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 199 |
+
|
| 200 |
+
def get_provider_states(self) -> list[dict[str, Any]]:
|
| 201 |
+
"""Return status dicts for all providers."""
|
| 202 |
+
return [state.to_dict() for state in self._states.values()]
|
| 203 |
+
|
| 204 |
+
def get_available_models(self) -> list[str]:
|
| 205 |
+
"""Return list of all recognized model names."""
|
| 206 |
+
return sorted(MODEL_TO_PROVIDER.keys())
|
| 207 |
+
|
| 208 |
+
def get_available_providers(self) -> list[str]:
|
| 209 |
+
"""Return list of all configured provider names."""
|
| 210 |
+
return sorted(self._states.keys())
|
| 211 |
+
|
| 212 |
+
def is_provider_available(self, provider: str) -> bool:
|
| 213 |
+
"""Check if a specific provider is currently available."""
|
| 214 |
+
state = self._states.get(provider)
|
| 215 |
+
if not state:
|
| 216 |
+
return False
|
| 217 |
+
return (
|
| 218 |
+
state.should_allow_request(
|
| 219 |
+
settings.cb_failure_threshold,
|
| 220 |
+
settings.cb_cooldown_seconds,
|
| 221 |
+
)
|
| 222 |
+
and state.check_rate_limit()
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# Module-level singleton
|
| 227 |
+
provider_router = ProviderRouter()
|
core/sessions.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Session Manager.
|
| 3 |
+
|
| 4 |
+
Manages multi-conversation sessions so agents can:
|
| 5 |
+
- Start fresh conversations (new chat)
|
| 6 |
+
- Resume specific past conversations by navigating to their saved URLs
|
| 7 |
+
- Track conversation URLs per provider and session
|
| 8 |
+
- Persist sessions in Upstash Redis (with in-memory fallback)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import time
|
| 15 |
+
import uuid
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from core.redis_client import redis_client
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("nancy.sessions")
|
| 21 |
+
|
| 22 |
+
# ββ Session Data Structure βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
|
| 24 |
+
class SessionRecord:
|
| 25 |
+
"""Represents a tracked conversation session."""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
session_id: str,
|
| 30 |
+
provider: str,
|
| 31 |
+
title: str | None = None,
|
| 32 |
+
conversation_url: str | None = None,
|
| 33 |
+
system_prompt: str | None = None,
|
| 34 |
+
) -> None:
|
| 35 |
+
self.session_id = session_id
|
| 36 |
+
self.provider = provider
|
| 37 |
+
self.title = title or f"Session {session_id[:8]}"
|
| 38 |
+
self.conversation_url = conversation_url
|
| 39 |
+
self.system_prompt = system_prompt
|
| 40 |
+
self.created_at: float = time.time()
|
| 41 |
+
self.last_used_at: float = time.time()
|
| 42 |
+
self.message_count: int = 0
|
| 43 |
+
self.status: str = "active" # active | archived | error
|
| 44 |
+
|
| 45 |
+
def to_dict(self) -> dict[str, Any]:
|
| 46 |
+
"""Serialize to dictionary for Redis storage and API responses."""
|
| 47 |
+
return {
|
| 48 |
+
"session_id": self.session_id,
|
| 49 |
+
"provider": self.provider,
|
| 50 |
+
"title": self.title,
|
| 51 |
+
"conversation_url": self.conversation_url,
|
| 52 |
+
"system_prompt": self.system_prompt,
|
| 53 |
+
"created_at": self.created_at,
|
| 54 |
+
"last_used_at": self.last_used_at,
|
| 55 |
+
"message_count": self.message_count,
|
| 56 |
+
"status": self.status,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def from_dict(cls, data: dict[str, Any]) -> "SessionRecord":
|
| 61 |
+
"""Deserialize from dictionary (from Redis)."""
|
| 62 |
+
session = cls(
|
| 63 |
+
session_id=data["session_id"],
|
| 64 |
+
provider=data["provider"],
|
| 65 |
+
title=data.get("title"),
|
| 66 |
+
conversation_url=data.get("conversation_url"),
|
| 67 |
+
system_prompt=data.get("system_prompt"),
|
| 68 |
+
)
|
| 69 |
+
session.created_at = data.get("created_at", time.time())
|
| 70 |
+
session.last_used_at = data.get("last_used_at", time.time())
|
| 71 |
+
session.message_count = data.get("message_count", 0)
|
| 72 |
+
session.status = data.get("status", "active")
|
| 73 |
+
return session
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ββ Session Store ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
|
| 78 |
+
class SessionStore:
|
| 79 |
+
"""
|
| 80 |
+
Manages conversation sessions.
|
| 81 |
+
Uses Upstash Redis for persistence if available, in-memory dict otherwise.
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
REDIS_PREFIX = "nancy:session:"
|
| 85 |
+
REDIS_INDEX_KEY = "nancy:sessions:index"
|
| 86 |
+
SESSION_TTL = 60 * 60 * 24 * 30 # 30 days in seconds
|
| 87 |
+
|
| 88 |
+
def __init__(self) -> None:
|
| 89 |
+
# In-memory fallback when Redis is not configured
|
| 90 |
+
self._sessions: dict[str, SessionRecord] = {}
|
| 91 |
+
|
| 92 |
+
# ββ CRUD βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
|
| 94 |
+
async def create_session(
|
| 95 |
+
self,
|
| 96 |
+
provider: str,
|
| 97 |
+
title: str | None = None,
|
| 98 |
+
system_prompt: str | None = None,
|
| 99 |
+
) -> SessionRecord:
|
| 100 |
+
"""
|
| 101 |
+
Create a new session and persist it.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
provider: Target provider key (e.g. "chatgpt", "gemini")
|
| 105 |
+
title: Optional human-readable title for the session
|
| 106 |
+
system_prompt: Optional system prompt to prepend in new chat
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
The newly created SessionRecord
|
| 110 |
+
"""
|
| 111 |
+
session_id = str(uuid.uuid4())
|
| 112 |
+
session = SessionRecord(
|
| 113 |
+
session_id=session_id,
|
| 114 |
+
provider=provider,
|
| 115 |
+
title=title,
|
| 116 |
+
system_prompt=system_prompt,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
await self._save(session)
|
| 120 |
+
logger.info("Created session '%s' for provider '%s'", session_id[:8], provider)
|
| 121 |
+
return session
|
| 122 |
+
|
| 123 |
+
async def get_session(self, session_id: str) -> SessionRecord | None:
|
| 124 |
+
"""Fetch a session by ID."""
|
| 125 |
+
# Try Redis first
|
| 126 |
+
data = await redis_client.get(f"{self.REDIS_PREFIX}{session_id}")
|
| 127 |
+
if data:
|
| 128 |
+
try:
|
| 129 |
+
import json
|
| 130 |
+
return SessionRecord.from_dict(json.loads(data))
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.warning("Failed to deserialize session '%s': %s", session_id[:8], e)
|
| 133 |
+
|
| 134 |
+
# Fall back to in-memory
|
| 135 |
+
return self._sessions.get(session_id)
|
| 136 |
+
|
| 137 |
+
async def list_sessions(self, provider: str | None = None) -> list[SessionRecord]:
|
| 138 |
+
"""
|
| 139 |
+
List all known sessions, optionally filtered by provider.
|
| 140 |
+
|
| 141 |
+
Returns sessions sorted by last_used_at descending.
|
| 142 |
+
"""
|
| 143 |
+
sessions: list[SessionRecord] = []
|
| 144 |
+
|
| 145 |
+
# Try Redis
|
| 146 |
+
if redis_client.is_enabled:
|
| 147 |
+
try:
|
| 148 |
+
import json
|
| 149 |
+
index = await redis_client.get(self.REDIS_INDEX_KEY)
|
| 150 |
+
if index:
|
| 151 |
+
session_ids: list[str] = json.loads(index)
|
| 152 |
+
for sid in session_ids:
|
| 153 |
+
session = await self.get_session(sid)
|
| 154 |
+
if session and session.status != "archived":
|
| 155 |
+
sessions.append(session)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.warning("Redis session list failed, using in-memory: %s", e)
|
| 158 |
+
|
| 159 |
+
if not sessions:
|
| 160 |
+
sessions = [s for s in self._sessions.values() if s.status != "archived"]
|
| 161 |
+
|
| 162 |
+
if provider:
|
| 163 |
+
sessions = [s for s in sessions if s.provider == provider]
|
| 164 |
+
|
| 165 |
+
sessions.sort(key=lambda s: s.last_used_at, reverse=True)
|
| 166 |
+
return sessions
|
| 167 |
+
|
| 168 |
+
async def update_session_url(
|
| 169 |
+
self,
|
| 170 |
+
session_id: str,
|
| 171 |
+
conversation_url: str,
|
| 172 |
+
message_count_delta: int = 1,
|
| 173 |
+
) -> None:
|
| 174 |
+
"""
|
| 175 |
+
Update a session's conversation URL after a task completes.
|
| 176 |
+
Called by the extension via the server when it reports back the active tab URL.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
session_id: The session to update
|
| 180 |
+
conversation_url: The current browser tab URL (e.g. chatgpt.com/c/abc123)
|
| 181 |
+
message_count_delta: How many messages to add to the count
|
| 182 |
+
"""
|
| 183 |
+
session = await self.get_session(session_id)
|
| 184 |
+
if not session:
|
| 185 |
+
logger.warning("Cannot update URL: session '%s' not found", session_id[:8])
|
| 186 |
+
return
|
| 187 |
+
|
| 188 |
+
session.conversation_url = conversation_url
|
| 189 |
+
session.last_used_at = time.time()
|
| 190 |
+
session.message_count += message_count_delta
|
| 191 |
+
|
| 192 |
+
await self._save(session)
|
| 193 |
+
logger.info(
|
| 194 |
+
"Session '%s' URL updated β %s (total messages: %d)",
|
| 195 |
+
session_id[:8], conversation_url, session.message_count
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
async def delete_session(self, session_id: str) -> bool:
|
| 199 |
+
"""
|
| 200 |
+
Soft-delete (archive) a session.
|
| 201 |
+
|
| 202 |
+
Returns True if the session was found and archived.
|
| 203 |
+
"""
|
| 204 |
+
session = await self.get_session(session_id)
|
| 205 |
+
if not session:
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
session.status = "archived"
|
| 209 |
+
await self._save(session)
|
| 210 |
+
logger.info("Session '%s' archived", session_id[:8])
|
| 211 |
+
return True
|
| 212 |
+
|
| 213 |
+
# ββ Internal helpers βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
+
|
| 215 |
+
async def _save(self, session: SessionRecord) -> None:
|
| 216 |
+
"""Persist session to Redis and in-memory."""
|
| 217 |
+
import json
|
| 218 |
+
data = json.dumps(session.to_dict())
|
| 219 |
+
|
| 220 |
+
# Always keep in-memory
|
| 221 |
+
self._sessions[session.session_id] = session
|
| 222 |
+
|
| 223 |
+
# Persist to Redis if available
|
| 224 |
+
if redis_client.is_enabled:
|
| 225 |
+
try:
|
| 226 |
+
await redis_client.set(
|
| 227 |
+
f"{self.REDIS_PREFIX}{session.session_id}",
|
| 228 |
+
data,
|
| 229 |
+
ex=self.SESSION_TTL,
|
| 230 |
+
)
|
| 231 |
+
# Update the index
|
| 232 |
+
index_data = await redis_client.get(self.REDIS_INDEX_KEY)
|
| 233 |
+
session_ids: list[str] = json.loads(index_data) if index_data else []
|
| 234 |
+
if session.session_id not in session_ids:
|
| 235 |
+
session_ids.append(session.session_id)
|
| 236 |
+
await redis_client.set(
|
| 237 |
+
self.REDIS_INDEX_KEY,
|
| 238 |
+
json.dumps(session_ids),
|
| 239 |
+
ex=self.SESSION_TTL,
|
| 240 |
+
)
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.warning("Failed to persist session to Redis: %s", e)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# Module-level singleton
|
| 246 |
+
session_store = SessionStore()
|
core/tools.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Executable Tools Core.
|
| 3 |
+
|
| 4 |
+
Provides standard server-side tools that can be exposed to AI agents (like Ultron)
|
| 5 |
+
and executed locally on the FastAPI backend.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import json
|
| 12 |
+
from typing import Any, Callable
|
| 13 |
+
|
| 14 |
+
from core.sessions import session_store
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("nancy.tools")
|
| 17 |
+
|
| 18 |
+
# βββ Built-in Tools Implementation ββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
async def web_search(query: str) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Search the web for the given query using DuckDuckGo Search.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
query: The search query string.
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
A text summary of top search results.
|
| 29 |
+
"""
|
| 30 |
+
logger.info("Executing web search: '%s'", query)
|
| 31 |
+
try:
|
| 32 |
+
from duckduckgo_search import DDGS
|
| 33 |
+
with DDGS() as ddgs:
|
| 34 |
+
results = list(ddgs.text(query, max_results=5))
|
| 35 |
+
if not results:
|
| 36 |
+
return "No search results found."
|
| 37 |
+
|
| 38 |
+
output = []
|
| 39 |
+
for i, r in enumerate(results, 1):
|
| 40 |
+
title = r.get("title", "No Title")
|
| 41 |
+
href = r.get("href", "#")
|
| 42 |
+
body = r.get("body", "")
|
| 43 |
+
output.append(f"[{i}] {title}\nURL: {href}\nSnippet: {body}\n")
|
| 44 |
+
return "\n".join(output)
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error("Web search failed for query '%s': %s", query, e)
|
| 47 |
+
return f"Error executing web search: {str(e)}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def nancy_new_chat(provider: str, system_prompt: str | None = None, title: str | None = None) -> str:
|
| 51 |
+
"""
|
| 52 |
+
Start a brand new conversation session with the specified provider.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
provider: Target chatbot provider (e.g. 'chatgpt', 'gemini', 'deepseek').
|
| 56 |
+
system_prompt: Optional initial prompt or instructions to prep in the new chat.
|
| 57 |
+
title: Optional custom session title.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
JSON string indicating new session details.
|
| 61 |
+
"""
|
| 62 |
+
logger.info("Creating new session for provider '%s'", provider)
|
| 63 |
+
try:
|
| 64 |
+
session = await session_store.create_session(
|
| 65 |
+
provider=provider,
|
| 66 |
+
title=title,
|
| 67 |
+
system_prompt=system_prompt
|
| 68 |
+
)
|
| 69 |
+
return json.dumps({
|
| 70 |
+
"status": "success",
|
| 71 |
+
"message": "New chat session created successfully. To use it, pass the session_id in the 'user' field in future completions.",
|
| 72 |
+
"session_id": session.session_id,
|
| 73 |
+
"provider": session.provider,
|
| 74 |
+
"title": session.title
|
| 75 |
+
})
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error("Failed to create new session: %s", e)
|
| 78 |
+
return json.dumps({"status": "error", "message": str(e)})
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def nancy_resume_chat(session_id: str) -> str:
|
| 82 |
+
"""
|
| 83 |
+
Retrieve details of a saved chat session to resume it.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
session_id: The UUID of the session.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
JSON string with session details.
|
| 90 |
+
"""
|
| 91 |
+
logger.info("Resuming session: '%s'", session_id)
|
| 92 |
+
try:
|
| 93 |
+
session = await session_store.get_session(session_id)
|
| 94 |
+
if not session:
|
| 95 |
+
return json.dumps({"status": "error", "message": f"Session {session_id} not found."})
|
| 96 |
+
return json.dumps({
|
| 97 |
+
"status": "success",
|
| 98 |
+
"session_id": session.session_id,
|
| 99 |
+
"provider": session.provider,
|
| 100 |
+
"title": session.title,
|
| 101 |
+
"conversation_url": session.conversation_url,
|
| 102 |
+
"system_prompt": session.system_prompt,
|
| 103 |
+
"message_count": session.message_count,
|
| 104 |
+
"status_state": session.status
|
| 105 |
+
})
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error("Failed to resume session '%s': %s", session_id, e)
|
| 108 |
+
return json.dumps({"status": "error", "message": str(e)})
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def nancy_list_sessions(provider: str | None = None) -> str:
|
| 112 |
+
"""
|
| 113 |
+
List all tracked conversation sessions, optionally filtered by provider.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
provider: Optional filter (e.g. 'chatgpt', 'gemini').
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
JSON string with session list.
|
| 120 |
+
"""
|
| 121 |
+
logger.info("Listing sessions. Filter: %s", provider)
|
| 122 |
+
try:
|
| 123 |
+
sessions = await session_store.list_sessions(provider=provider)
|
| 124 |
+
serialized = [s.to_dict() for s in sessions]
|
| 125 |
+
return json.dumps({
|
| 126 |
+
"status": "success",
|
| 127 |
+
"sessions": serialized
|
| 128 |
+
})
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error("Failed to list sessions: %s", e)
|
| 131 |
+
return json.dumps({"status": "error", "message": str(e)})
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# βββ Tool Registry & Dispatcher ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
|
| 136 |
+
class ToolRegistry:
|
| 137 |
+
"""Registry mapping tool names to their async handlers and schemas."""
|
| 138 |
+
|
| 139 |
+
def __init__(self) -> None:
|
| 140 |
+
self._handlers: dict[str, Callable[..., Any]] = {}
|
| 141 |
+
self._schemas: list[dict[str, Any]] = []
|
| 142 |
+
|
| 143 |
+
# Register our built-in tools
|
| 144 |
+
self.register("web_search", web_search, {
|
| 145 |
+
"type": "function",
|
| 146 |
+
"function": {
|
| 147 |
+
"name": "web_search",
|
| 148 |
+
"description": "Search the web for real-time information or questions requiring search.",
|
| 149 |
+
"parameters": {
|
| 150 |
+
"type": "object",
|
| 151 |
+
"properties": {
|
| 152 |
+
"query": {
|
| 153 |
+
"type": "string",
|
| 154 |
+
"description": "Search query text."
|
| 155 |
+
}
|
| 156 |
+
},
|
| 157 |
+
"required": ["query"]
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
self.register("nancy_new_chat", nancy_new_chat, {
|
| 163 |
+
"type": "function",
|
| 164 |
+
"function": {
|
| 165 |
+
"name": "nancy_new_chat",
|
| 166 |
+
"description": "Start a brand new conversation session with a chatbot provider.",
|
| 167 |
+
"parameters": {
|
| 168 |
+
"type": "object",
|
| 169 |
+
"properties": {
|
| 170 |
+
"provider": {
|
| 171 |
+
"type": "string",
|
| 172 |
+
"description": "Target provider key, e.g. 'chatgpt', 'gemini', 'deepseek'."
|
| 173 |
+
},
|
| 174 |
+
"system_prompt": {
|
| 175 |
+
"type": "string",
|
| 176 |
+
"description": "Optional instructions/rules to prepend to this conversation."
|
| 177 |
+
},
|
| 178 |
+
"title": {
|
| 179 |
+
"type": "string",
|
| 180 |
+
"description": "Optional human-readable title."
|
| 181 |
+
}
|
| 182 |
+
},
|
| 183 |
+
"required": ["provider"]
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
self.register("nancy_resume_chat", nancy_resume_chat, {
|
| 189 |
+
"type": "function",
|
| 190 |
+
"function": {
|
| 191 |
+
"name": "nancy_resume_chat",
|
| 192 |
+
"description": "Retrieve information on an existing saved chat session by ID.",
|
| 193 |
+
"parameters": {
|
| 194 |
+
"type": "object",
|
| 195 |
+
"properties": {
|
| 196 |
+
"session_id": {
|
| 197 |
+
"type": "string",
|
| 198 |
+
"description": "The session ID UUID."
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
"required": ["session_id"]
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
self.register("nancy_list_sessions", nancy_list_sessions, {
|
| 207 |
+
"type": "function",
|
| 208 |
+
"function": {
|
| 209 |
+
"name": "nancy_list_sessions",
|
| 210 |
+
"description": "List all active saved conversation sessions in Nancy.",
|
| 211 |
+
"parameters": {
|
| 212 |
+
"type": "object",
|
| 213 |
+
"properties": {
|
| 214 |
+
"provider": {
|
| 215 |
+
"type": "string",
|
| 216 |
+
"description": "Optional chatbot provider to filter by."
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
def register(self, name: str, handler: Callable[..., Any], schema: dict[str, Any]) -> None:
|
| 224 |
+
"""Register a new tool."""
|
| 225 |
+
self._handlers[name] = handler
|
| 226 |
+
self._schemas.append(schema)
|
| 227 |
+
|
| 228 |
+
def get_schemas(self) -> list[dict[str, Any]]:
|
| 229 |
+
"""Get the schemas of all registered tools."""
|
| 230 |
+
return self._schemas
|
| 231 |
+
|
| 232 |
+
async def execute(self, name: str, arguments: dict[str, Any]) -> str:
|
| 233 |
+
"""Execute a tool by name with arguments."""
|
| 234 |
+
handler = self._handlers.get(name)
|
| 235 |
+
if not handler:
|
| 236 |
+
raise ValueError(f"Tool '{name}' is not registered.")
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
return await handler(**arguments)
|
| 240 |
+
except Exception as e:
|
| 241 |
+
logger.error("Error executing tool '%s': %s", name, e)
|
| 242 |
+
return f"Execution error: {str(e)}"
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# Module-level singleton registry
|
| 246 |
+
tool_registry = ToolRegistry()
|
main.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β FastAPI Main Entry Point.
|
| 3 |
+
|
| 4 |
+
Initializes the FastAPI application, registers routers (API, Extension, Health),
|
| 5 |
+
manages startup/shutdown hooks (Redis, logging), and configures CORS middleware.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import sys
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
from fastapi import FastAPI, Request
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
|
| 17 |
+
from config import settings
|
| 18 |
+
from core.redis_client import redis_client
|
| 19 |
+
from models.openai import ErrorDetail, ErrorResponse
|
| 20 |
+
from routers.api import router as api_router
|
| 21 |
+
from routers.extension import router as extension_router
|
| 22 |
+
from routers.health import router as health_router
|
| 23 |
+
from routers.sessions import router as sessions_router
|
| 24 |
+
from routers.admin import router as admin_router
|
| 25 |
+
|
| 26 |
+
# ββ Logging Configuration βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
| 29 |
+
format="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
| 30 |
+
handlers=[logging.StreamHandler(sys.stdout)],
|
| 31 |
+
)
|
| 32 |
+
logger = logging.getLogger("nancy.main")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ββ Lifespan Context Manager ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
+
@asynccontextmanager
|
| 37 |
+
async def lifespan(app: FastAPI):
|
| 38 |
+
"""Handles startup and shutdown hooks for Nancy."""
|
| 39 |
+
logger.info("Initializing Nancy HF Space backend...")
|
| 40 |
+
|
| 41 |
+
# Startup Upstash Redis if configured
|
| 42 |
+
await redis_client.startup()
|
| 43 |
+
|
| 44 |
+
yield
|
| 45 |
+
|
| 46 |
+
logger.info("Shutting down Nancy HF Space backend...")
|
| 47 |
+
# Shutdown Upstash Redis
|
| 48 |
+
await redis_client.shutdown()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ FastAPI App Initialization ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
app = FastAPI(
|
| 53 |
+
title="Nancy",
|
| 54 |
+
description="Free Chatbot to OpenAI-Compatible API Orchestrator",
|
| 55 |
+
version="0.1.0",
|
| 56 |
+
lifespan=lifespan,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# ββ CORS Middleware βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
# Allow access from Chrome extension environment (chrome-extension://*)
|
| 61 |
+
app.add_middleware(
|
| 62 |
+
CORSMiddleware,
|
| 63 |
+
allow_origins=settings.cors_origins,
|
| 64 |
+
allow_credentials=True,
|
| 65 |
+
allow_methods=["*"],
|
| 66 |
+
allow_headers=["*"],
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# ββ Router Registration βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
app.include_router(health_router)
|
| 71 |
+
app.include_router(extension_router)
|
| 72 |
+
app.include_router(api_router)
|
| 73 |
+
app.include_router(sessions_router)
|
| 74 |
+
app.include_router(admin_router)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ββ Global Exception Handlers βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
@app.exception_handler(Exception)
|
| 79 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 80 |
+
"""Catch-all standard OpenAI-compatible error response for internal errors."""
|
| 81 |
+
logger.error("Unhandled error at %s: %s", request.url.path, exc, exc_info=True)
|
| 82 |
+
error_detail = ErrorDetail(
|
| 83 |
+
message=f"Nancy server internal error: {str(exc)}",
|
| 84 |
+
type="internal_server_error",
|
| 85 |
+
code="500",
|
| 86 |
+
)
|
| 87 |
+
return JSONResponse(
|
| 88 |
+
status_code=500,
|
| 89 |
+
content=ErrorResponse(error=error_detail).model_dump(),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@app.get("/")
|
| 94 |
+
async def root_index():
|
| 95 |
+
"""Welcome index page for browser landing."""
|
| 96 |
+
return {
|
| 97 |
+
"name": "Nancy",
|
| 98 |
+
"description": "API Orchestrator for converting free chatbot interfaces into structured APIs.",
|
| 99 |
+
"version": "0.1.0",
|
| 100 |
+
"docs_url": "/docs",
|
| 101 |
+
"health_check": "/health",
|
| 102 |
+
}
|
models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Nancy models package."""
|
models/openai.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β OpenAI-compatible Pydantic v2 schemas.
|
| 3 |
+
|
| 4 |
+
These models exactly match the OpenAI Chat Completions API response format
|
| 5 |
+
so that the official ``openai`` Python SDK works seamlessly.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from typing import Any, Literal
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _chatcmpl_id() -> str:
|
| 21 |
+
"""Generate an OpenAI-style completion ID."""
|
| 22 |
+
return f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _unix_ts() -> int:
|
| 26 |
+
"""Current UTC Unix timestamp."""
|
| 27 |
+
return int(time.time())
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ββ Request Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ChatMessage(BaseModel):
|
| 34 |
+
"""A single chat message in the OpenAI format."""
|
| 35 |
+
|
| 36 |
+
role: Literal["system", "user", "assistant", "function", "tool"] = Field(
|
| 37 |
+
..., description="The role of the message author."
|
| 38 |
+
)
|
| 39 |
+
content: str | None = Field(
|
| 40 |
+
default=None, description="The text content of the message."
|
| 41 |
+
)
|
| 42 |
+
name: str | None = Field(
|
| 43 |
+
default=None, description="Optional name for the message author."
|
| 44 |
+
)
|
| 45 |
+
tool_call_id: str | None = Field(
|
| 46 |
+
default=None, description="Tool call that this message is responding to."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ChatCompletionRequest(BaseModel):
|
| 51 |
+
"""
|
| 52 |
+
Incoming request body for ``POST /v1/chat/completions``.
|
| 53 |
+
|
| 54 |
+
Mirrors the subset of OpenAI fields that Nancy supports.
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
model: str = Field(
|
| 58 |
+
..., description="Model/provider name, e.g. 'chatgpt', 'gemini'."
|
| 59 |
+
)
|
| 60 |
+
messages: list[ChatMessage] = Field(
|
| 61 |
+
..., min_length=1, description="Conversation messages."
|
| 62 |
+
)
|
| 63 |
+
stream: bool = Field(
|
| 64 |
+
default=False, description="Whether to stream the response via SSE."
|
| 65 |
+
)
|
| 66 |
+
temperature: float | None = Field(
|
| 67 |
+
default=None, ge=0.0, le=2.0, description="Sampling temperature."
|
| 68 |
+
)
|
| 69 |
+
max_tokens: int | None = Field(
|
| 70 |
+
default=None, ge=1, description="Maximum tokens to generate."
|
| 71 |
+
)
|
| 72 |
+
top_p: float | None = Field(
|
| 73 |
+
default=None, ge=0.0, le=1.0, description="Nucleus sampling parameter."
|
| 74 |
+
)
|
| 75 |
+
stop: str | list[str] | None = Field(
|
| 76 |
+
default=None, description="Stop sequences."
|
| 77 |
+
)
|
| 78 |
+
user: str | None = Field(
|
| 79 |
+
default=None, description="End-user identifier for abuse tracking."
|
| 80 |
+
)
|
| 81 |
+
tools: list[dict] | None = Field(
|
| 82 |
+
default=None, description="A list of tools the model may call."
|
| 83 |
+
)
|
| 84 |
+
tool_choice: str | dict | None = Field(
|
| 85 |
+
default=None, description="Controls which (if any) tool is called."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ββ Response Models β Non-streaming ββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class UsageInfo(BaseModel):
|
| 93 |
+
"""Token usage statistics."""
|
| 94 |
+
|
| 95 |
+
prompt_tokens: int = 0
|
| 96 |
+
completion_tokens: int = 0
|
| 97 |
+
total_tokens: int = 0
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class ChoiceMessage(BaseModel):
|
| 101 |
+
"""The assistant's response message in a non-streaming completion."""
|
| 102 |
+
|
| 103 |
+
role: Literal["assistant"] = "assistant"
|
| 104 |
+
content: str | None = ""
|
| 105 |
+
tool_calls: list[dict] | None = Field(
|
| 106 |
+
default=None, description="The tool calls generated by the model."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class Choice(BaseModel):
|
| 111 |
+
"""A single choice in a non-streaming completion response."""
|
| 112 |
+
|
| 113 |
+
index: int = 0
|
| 114 |
+
message: ChoiceMessage = Field(default_factory=ChoiceMessage)
|
| 115 |
+
finish_reason: Literal["stop", "length", "content_filter", "tool_calls"] | None = None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class ChatCompletionResponse(BaseModel):
|
| 119 |
+
"""
|
| 120 |
+
Non-streaming response for ``POST /v1/chat/completions``.
|
| 121 |
+
|
| 122 |
+
Matches ``openai.types.chat.ChatCompletion``.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
id: str = Field(default_factory=_chatcmpl_id)
|
| 126 |
+
object: Literal["chat.completion"] = "chat.completion"
|
| 127 |
+
created: int = Field(default_factory=_unix_ts)
|
| 128 |
+
model: str = ""
|
| 129 |
+
choices: list[Choice] = Field(default_factory=lambda: [Choice()])
|
| 130 |
+
usage: UsageInfo = Field(default_factory=UsageInfo)
|
| 131 |
+
system_fingerprint: str | None = None
|
| 132 |
+
|
| 133 |
+
@classmethod
|
| 134 |
+
def from_content(
|
| 135 |
+
cls,
|
| 136 |
+
content: str,
|
| 137 |
+
model: str,
|
| 138 |
+
finish_reason: str = "stop",
|
| 139 |
+
) -> ChatCompletionResponse:
|
| 140 |
+
"""Build a complete response from a single content string."""
|
| 141 |
+
return cls(
|
| 142 |
+
model=model,
|
| 143 |
+
choices=[
|
| 144 |
+
Choice(
|
| 145 |
+
index=0,
|
| 146 |
+
message=ChoiceMessage(content=content),
|
| 147 |
+
finish_reason=finish_reason, # type: ignore[arg-type]
|
| 148 |
+
)
|
| 149 |
+
],
|
| 150 |
+
usage=UsageInfo(
|
| 151 |
+
prompt_tokens=0,
|
| 152 |
+
completion_tokens=len(content.split()),
|
| 153 |
+
total_tokens=len(content.split()),
|
| 154 |
+
),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ββ Response Models β Streaming (SSE chunks) βββββββββββββββββββββββββββββββββ
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class DeltaContent(BaseModel):
|
| 162 |
+
"""
|
| 163 |
+
Delta object inside a streaming chunk.
|
| 164 |
+
|
| 165 |
+
On the first chunk, ``role`` is set to ``"assistant"`` with no content.
|
| 166 |
+
On subsequent chunks, ``content`` carries the text fragment.
|
| 167 |
+
On the final chunk, both may be absent (empty delta).
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
role: Literal["assistant"] | None = None
|
| 171 |
+
content: str | None = None
|
| 172 |
+
tool_calls: list[dict] | None = Field(
|
| 173 |
+
default=None, description="The tool calls generated by the model."
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class StreamChoice(BaseModel):
|
| 178 |
+
"""A single choice in a streaming chunk."""
|
| 179 |
+
|
| 180 |
+
index: int = 0
|
| 181 |
+
delta: DeltaContent = Field(default_factory=DeltaContent)
|
| 182 |
+
finish_reason: Literal["stop", "length", "content_filter", "tool_calls"] | None = None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class ChatCompletionChunk(BaseModel):
|
| 186 |
+
"""
|
| 187 |
+
A single SSE chunk for streaming ``POST /v1/chat/completions``.
|
| 188 |
+
|
| 189 |
+
Matches ``openai.types.chat.ChatCompletionChunk``.
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
id: str = Field(default_factory=_chatcmpl_id)
|
| 193 |
+
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
|
| 194 |
+
created: int = Field(default_factory=_unix_ts)
|
| 195 |
+
model: str = ""
|
| 196 |
+
choices: list[StreamChoice] = Field(default_factory=lambda: [StreamChoice()])
|
| 197 |
+
system_fingerprint: str | None = None
|
| 198 |
+
|
| 199 |
+
def to_sse_data(self) -> str:
|
| 200 |
+
"""Serialize to the JSON string used in ``data: ...`` SSE frames."""
|
| 201 |
+
return self.model_dump_json(exclude_none=False)
|
| 202 |
+
|
| 203 |
+
# ββ Convenience factories βββββββββββββββββββββββββββββββββββββββββ
|
| 204 |
+
|
| 205 |
+
@classmethod
|
| 206 |
+
def first_chunk(cls, completion_id: str, model: str) -> ChatCompletionChunk:
|
| 207 |
+
"""Role-only opening chunk (no content)."""
|
| 208 |
+
return cls(
|
| 209 |
+
id=completion_id,
|
| 210 |
+
model=model,
|
| 211 |
+
choices=[
|
| 212 |
+
StreamChoice(
|
| 213 |
+
delta=DeltaContent(role="assistant"),
|
| 214 |
+
finish_reason=None,
|
| 215 |
+
)
|
| 216 |
+
],
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
@classmethod
|
| 220 |
+
def content_chunk(
|
| 221 |
+
cls, completion_id: str, model: str, content: str
|
| 222 |
+
) -> ChatCompletionChunk:
|
| 223 |
+
"""A chunk carrying a text fragment."""
|
| 224 |
+
return cls(
|
| 225 |
+
id=completion_id,
|
| 226 |
+
model=model,
|
| 227 |
+
choices=[
|
| 228 |
+
StreamChoice(
|
| 229 |
+
delta=DeltaContent(content=content),
|
| 230 |
+
finish_reason=None,
|
| 231 |
+
)
|
| 232 |
+
],
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
@classmethod
|
| 236 |
+
def final_chunk(
|
| 237 |
+
cls,
|
| 238 |
+
completion_id: str,
|
| 239 |
+
model: str,
|
| 240 |
+
finish_reason: str = "stop",
|
| 241 |
+
) -> ChatCompletionChunk:
|
| 242 |
+
"""Terminal chunk with ``finish_reason`` and empty delta."""
|
| 243 |
+
return cls(
|
| 244 |
+
id=completion_id,
|
| 245 |
+
model=model,
|
| 246 |
+
choices=[
|
| 247 |
+
StreamChoice(
|
| 248 |
+
delta=DeltaContent(),
|
| 249 |
+
finish_reason=finish_reason, # type: ignore[arg-type]
|
| 250 |
+
)
|
| 251 |
+
],
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ββ /v1/models response ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class ModelInfo(BaseModel):
|
| 259 |
+
"""A single model entry returned by ``GET /v1/models``."""
|
| 260 |
+
|
| 261 |
+
id: str
|
| 262 |
+
object: Literal["model"] = "model"
|
| 263 |
+
created: int = Field(default_factory=_unix_ts)
|
| 264 |
+
owned_by: str = "nancy"
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class ModelListResponse(BaseModel):
|
| 268 |
+
"""Response body for ``GET /v1/models``."""
|
| 269 |
+
|
| 270 |
+
object: Literal["list"] = "list"
|
| 271 |
+
data: list[ModelInfo] = Field(default_factory=list)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ββ Error response ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class ErrorDetail(BaseModel):
|
| 278 |
+
"""OpenAI-style error detail."""
|
| 279 |
+
|
| 280 |
+
message: str
|
| 281 |
+
type: str = "invalid_request_error"
|
| 282 |
+
param: str | None = None
|
| 283 |
+
code: str | None = None
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class ErrorResponse(BaseModel):
|
| 287 |
+
"""OpenAI-style error envelope."""
|
| 288 |
+
|
| 289 |
+
error: ErrorDetail
|
models/provider.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β Provider models.
|
| 3 |
+
|
| 4 |
+
Tracks per-provider runtime state: rate limits, circuit breaker status,
|
| 5 |
+
and routing metadata.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
try:
|
| 12 |
+
from enum import StrEnum
|
| 13 |
+
except ImportError:
|
| 14 |
+
import enum
|
| 15 |
+
class StrEnum(str, enum.Enum):
|
| 16 |
+
pass
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from pydantic import BaseModel, Field
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class CircuitState(StrEnum):
|
| 23 |
+
"""Circuit breaker states."""
|
| 24 |
+
|
| 25 |
+
CLOSED = "closed" # healthy β requests flow through
|
| 26 |
+
OPEN = "open" # tripped β requests are blocked
|
| 27 |
+
HALF_OPEN = "half_open" # cooldown expired β next request is a probe
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ProviderConfig(BaseModel):
|
| 31 |
+
"""
|
| 32 |
+
Static configuration for a single provider.
|
| 33 |
+
|
| 34 |
+
Loaded from the ``PROVIDERS_CONFIG`` environment variable or defaults.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
rpm: int = Field(default=10, description="Requests per minute limit.")
|
| 38 |
+
tpm: int = Field(default=40000, description="Tokens per minute limit.")
|
| 39 |
+
url_pattern: str = Field(
|
| 40 |
+
default="", description="Base URL pattern for the chatbot UI."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ProviderState:
|
| 45 |
+
"""
|
| 46 |
+
Mutable runtime state for a single provider.
|
| 47 |
+
|
| 48 |
+
This is NOT a Pydantic model because it holds mutable counters and
|
| 49 |
+
timestamps that change on every request.
|
| 50 |
+
|
| 51 |
+
Attributes:
|
| 52 |
+
name: Provider identifier (e.g. ``"chatgpt"``).
|
| 53 |
+
config: Static provider configuration.
|
| 54 |
+
circuit_state: Current circuit breaker state.
|
| 55 |
+
consecutive_failures: Count of back-to-back failures.
|
| 56 |
+
last_failure_time: Timestamp of the most recent failure.
|
| 57 |
+
last_success_time: Timestamp of the most recent success.
|
| 58 |
+
request_timestamps: Rolling window of request timestamps for RPM.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
__slots__ = (
|
| 62 |
+
"name",
|
| 63 |
+
"config",
|
| 64 |
+
"circuit_state",
|
| 65 |
+
"consecutive_failures",
|
| 66 |
+
"last_failure_time",
|
| 67 |
+
"last_success_time",
|
| 68 |
+
"request_timestamps",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
def __init__(self, name: str, config: ProviderConfig) -> None:
|
| 72 |
+
self.name = name
|
| 73 |
+
self.config = config
|
| 74 |
+
self.circuit_state = CircuitState.CLOSED
|
| 75 |
+
self.consecutive_failures: int = 0
|
| 76 |
+
self.last_failure_time: float = 0.0
|
| 77 |
+
self.last_success_time: float = 0.0
|
| 78 |
+
self.request_timestamps: list[float] = []
|
| 79 |
+
|
| 80 |
+
# ββ Circuit Breaker βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
def record_success(self) -> None:
|
| 83 |
+
"""Reset failure counter and close the circuit."""
|
| 84 |
+
self.consecutive_failures = 0
|
| 85 |
+
self.last_success_time = time.time()
|
| 86 |
+
self.circuit_state = CircuitState.CLOSED
|
| 87 |
+
|
| 88 |
+
def record_failure(self, threshold: int) -> None:
|
| 89 |
+
"""Increment failure counter; trip if threshold is reached."""
|
| 90 |
+
self.consecutive_failures += 1
|
| 91 |
+
self.last_failure_time = time.time()
|
| 92 |
+
if self.consecutive_failures >= threshold:
|
| 93 |
+
self.circuit_state = CircuitState.OPEN
|
| 94 |
+
|
| 95 |
+
def should_allow_request(self, threshold: int, cooldown: float) -> bool:
|
| 96 |
+
"""
|
| 97 |
+
Check whether the circuit breaker allows a request.
|
| 98 |
+
|
| 99 |
+
- CLOSED β always allow.
|
| 100 |
+
- OPEN β allow only if cooldown has elapsed (transition to HALF_OPEN).
|
| 101 |
+
- HALF_OPEN β allow (it's a probe request).
|
| 102 |
+
"""
|
| 103 |
+
if self.circuit_state == CircuitState.CLOSED:
|
| 104 |
+
return True
|
| 105 |
+
if self.circuit_state == CircuitState.OPEN:
|
| 106 |
+
elapsed = time.time() - self.last_failure_time
|
| 107 |
+
if elapsed >= cooldown:
|
| 108 |
+
self.circuit_state = CircuitState.HALF_OPEN
|
| 109 |
+
return True
|
| 110 |
+
return False
|
| 111 |
+
# HALF_OPEN β allow the probe
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
# ββ Rate Limiting (sliding window) ββββββββββββββββββββββββββββββββ
|
| 115 |
+
|
| 116 |
+
def check_rate_limit(self) -> bool:
|
| 117 |
+
"""
|
| 118 |
+
Return True if the provider is within its RPM budget.
|
| 119 |
+
|
| 120 |
+
Prunes timestamps older than 60 seconds.
|
| 121 |
+
"""
|
| 122 |
+
now = time.time()
|
| 123 |
+
cutoff = now - 60.0
|
| 124 |
+
self.request_timestamps = [
|
| 125 |
+
ts for ts in self.request_timestamps if ts > cutoff
|
| 126 |
+
]
|
| 127 |
+
return len(self.request_timestamps) < self.config.rpm
|
| 128 |
+
|
| 129 |
+
def record_request(self) -> None:
|
| 130 |
+
"""Record a request timestamp for RPM tracking."""
|
| 131 |
+
self.request_timestamps.append(time.time())
|
| 132 |
+
|
| 133 |
+
# ββ Serialization βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
|
| 135 |
+
def to_dict(self) -> dict[str, Any]:
|
| 136 |
+
"""Serialize for health / debug endpoints."""
|
| 137 |
+
now = time.time()
|
| 138 |
+
cutoff = now - 60.0
|
| 139 |
+
active_rpm = len([ts for ts in self.request_timestamps if ts > cutoff])
|
| 140 |
+
return {
|
| 141 |
+
"name": self.name,
|
| 142 |
+
"circuit_state": self.circuit_state.value,
|
| 143 |
+
"consecutive_failures": self.consecutive_failures,
|
| 144 |
+
"rpm_current": active_rpm,
|
| 145 |
+
"rpm_limit": self.config.rpm,
|
| 146 |
+
"last_failure_ago": (
|
| 147 |
+
round(now - self.last_failure_time, 1)
|
| 148 |
+
if self.last_failure_time
|
| 149 |
+
else None
|
| 150 |
+
),
|
| 151 |
+
"last_success_ago": (
|
| 152 |
+
round(now - self.last_success_time, 1)
|
| 153 |
+
if self.last_success_time
|
| 154 |
+
else None
|
| 155 |
+
),
|
| 156 |
+
}
|
models/task.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy β Task models.
|
| 3 |
+
|
| 4 |
+
A *task* is the unit of work that flows through the system:
|
| 5 |
+
1. API receives a chat completion request β creates a Task
|
| 6 |
+
2. Task enters the queue β extension picks it up via SSE
|
| 7 |
+
3. Extension sends response chunks back β routed to the waiting API caller
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
try:
|
| 16 |
+
from enum import StrEnum
|
| 17 |
+
except ImportError:
|
| 18 |
+
import enum
|
| 19 |
+
class StrEnum(str, enum.Enum):
|
| 20 |
+
pass
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel, Field
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TaskStatus(StrEnum):
|
| 27 |
+
"""Lifecycle states of a task."""
|
| 28 |
+
|
| 29 |
+
PENDING = "pending"
|
| 30 |
+
ASSIGNED = "assigned"
|
| 31 |
+
STREAMING = "streaming"
|
| 32 |
+
COMPLETED = "completed"
|
| 33 |
+
FAILED = "failed"
|
| 34 |
+
TIMED_OUT = "timed_out"
|
| 35 |
+
CANCELLED = "cancelled"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class Task(BaseModel):
|
| 39 |
+
"""
|
| 40 |
+
Represents a single chat completion request flowing through the relay.
|
| 41 |
+
|
| 42 |
+
The ``completion_id`` is the OpenAI-format ``chatcmpl-*`` ID that will be
|
| 43 |
+
used across all SSE chunks for this task.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
task_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
| 47 |
+
completion_id: str = Field(
|
| 48 |
+
default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
| 49 |
+
)
|
| 50 |
+
provider: str = Field(..., description="Target provider, e.g. 'chatgpt'.")
|
| 51 |
+
model: str = Field(..., description="Original model name from the request.")
|
| 52 |
+
messages: list[dict[str, Any]] = Field(
|
| 53 |
+
..., description="Chat messages to send to the provider."
|
| 54 |
+
)
|
| 55 |
+
temperature: float | None = None
|
| 56 |
+
max_tokens: int | None = None
|
| 57 |
+
stream: bool = True
|
| 58 |
+
status: TaskStatus = TaskStatus.PENDING
|
| 59 |
+
created_at: float = Field(default_factory=time.time)
|
| 60 |
+
assigned_at: float | None = None
|
| 61 |
+
completed_at: float | None = None
|
| 62 |
+
error: str | None = None
|
| 63 |
+
|
| 64 |
+
# ββ Session-aware routing fields ββββββββββββββββββββββββββββββββββ
|
| 65 |
+
session_id: str | None = Field(
|
| 66 |
+
default=None,
|
| 67 |
+
description="Optional session ID for conversation tracking."
|
| 68 |
+
)
|
| 69 |
+
conversation_url: str | None = Field(
|
| 70 |
+
default=None,
|
| 71 |
+
description="URL to navigate to when resuming a session (e.g. chatgpt.com/c/<id>)."
|
| 72 |
+
)
|
| 73 |
+
action: str = Field(
|
| 74 |
+
default="continue",
|
| 75 |
+
description="'new_chat' = open fresh conversation, 'resume_chat' = navigate to URL, 'continue' = send to current tab."
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# These fields are NOT serialized β they are runtime-only handles.
|
| 79 |
+
model_config = {"arbitrary_types_allowed": True}
|
| 80 |
+
|
| 81 |
+
def to_extension_payload(self) -> dict[str, Any]:
|
| 82 |
+
"""
|
| 83 |
+
Serialize the task for delivery to the Chrome extension via SSE.
|
| 84 |
+
|
| 85 |
+
Includes session navigation fields so the extension knows whether to
|
| 86 |
+
open a new chat, resume a specific conversation, or continue in the current tab.
|
| 87 |
+
"""
|
| 88 |
+
return {
|
| 89 |
+
"task_id": self.task_id,
|
| 90 |
+
"provider": self.provider,
|
| 91 |
+
"model": self.model,
|
| 92 |
+
"messages": self.messages,
|
| 93 |
+
"temperature": self.temperature,
|
| 94 |
+
"max_tokens": self.max_tokens,
|
| 95 |
+
# Session navigation
|
| 96 |
+
"session_id": self.session_id,
|
| 97 |
+
"conversation_url": self.conversation_url,
|
| 98 |
+
"action": self.action,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
def to_status_dict(self) -> dict[str, Any]:
|
| 102 |
+
"""Compact status representation for health / debug endpoints."""
|
| 103 |
+
return {
|
| 104 |
+
"task_id": self.task_id,
|
| 105 |
+
"provider": self.provider,
|
| 106 |
+
"model": self.model,
|
| 107 |
+
"status": self.status.value,
|
| 108 |
+
"created_at": self.created_at,
|
| 109 |
+
"assigned_at": self.assigned_at,
|
| 110 |
+
"completed_at": self.completed_at,
|
| 111 |
+
"error": self.error,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class TaskHandle:
|
| 116 |
+
"""
|
| 117 |
+
Runtime handle for an in-flight task.
|
| 118 |
+
|
| 119 |
+
Bundles the ``Task`` data model with the asyncio primitives needed
|
| 120 |
+
to coordinate between the API caller and the extension relay.
|
| 121 |
+
|
| 122 |
+
Attributes:
|
| 123 |
+
task: The Task data model.
|
| 124 |
+
chunk_queue: Queue where the extension pushes response chunks.
|
| 125 |
+
done_event: Event set when the extension signals completion.
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
__slots__ = ("task", "chunk_queue", "done_event", "created_at")
|
| 129 |
+
|
| 130 |
+
def __init__(self, task: Task) -> None:
|
| 131 |
+
self.task = task
|
| 132 |
+
self.chunk_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
| 133 |
+
self.done_event = asyncio.Event()
|
| 134 |
+
self.created_at = time.time()
|
| 135 |
+
|
| 136 |
+
@property
|
| 137 |
+
def task_id(self) -> str:
|
| 138 |
+
return self.task.task_id
|
| 139 |
+
|
| 140 |
+
def push_chunk(self, chunk: str) -> None:
|
| 141 |
+
"""
|
| 142 |
+
Enqueue a text chunk from the extension.
|
| 143 |
+
|
| 144 |
+
A ``None`` sentinel signals end-of-stream.
|
| 145 |
+
"""
|
| 146 |
+
self.chunk_queue.put_nowait(chunk)
|
| 147 |
+
|
| 148 |
+
def finish(self, error: str | None = None) -> None:
|
| 149 |
+
"""
|
| 150 |
+
Mark the task as done.
|
| 151 |
+
|
| 152 |
+
Pushes a ``None`` sentinel into the chunk queue and sets the
|
| 153 |
+
done event so the API handler can stop waiting.
|
| 154 |
+
"""
|
| 155 |
+
if error:
|
| 156 |
+
self.task.status = TaskStatus.FAILED
|
| 157 |
+
self.task.error = error
|
| 158 |
+
else:
|
| 159 |
+
self.task.status = TaskStatus.COMPLETED
|
| 160 |
+
self.task.completed_at = time.time()
|
| 161 |
+
self.chunk_queue.put_nowait(None) # sentinel
|
| 162 |
+
self.done_event.set()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class ExtensionResponseChunk(BaseModel):
|
| 166 |
+
"""
|
| 167 |
+
Payload sent by the Chrome extension via ``POST /ext/response``.
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
task_id: str = Field(..., description="ID of the task this chunk belongs to.")
|
| 171 |
+
chunk: str = Field(default="", description="Text fragment (may be empty on final).")
|
| 172 |
+
is_done: bool = Field(
|
| 173 |
+
default=False, description="True on the final chunk."
|
| 174 |
+
)
|
| 175 |
+
error: str | None = Field(
|
| 176 |
+
default=None, description="Error message if the extension failed."
|
| 177 |
+
)
|
| 178 |
+
# Session URL reporting: extension reports back the current tab URL after task completes
|
| 179 |
+
conversation_url: str | None = Field(
|
| 180 |
+
default=None,
|
| 181 |
+
description="Current browser tab URL after task completes. Used to update session records."
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class ExtensionHeartbeat(BaseModel):
|
| 186 |
+
"""
|
| 187 |
+
Payload sent by the Chrome extension via ``POST /ext/heartbeat``.
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
extension_id: str = Field(
|
| 191 |
+
default="default",
|
| 192 |
+
description="Unique extension instance identifier.",
|
| 193 |
+
)
|
| 194 |
+
timestamp: float = Field(
|
| 195 |
+
default_factory=time.time,
|
| 196 |
+
description="Client-side UTC Unix timestamp.",
|
| 197 |
+
)
|
| 198 |
+
active_tasks: list[str] = Field(
|
| 199 |
+
default_factory=list,
|
| 200 |
+
description="Task IDs currently being processed by this extension.",
|
| 201 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.34.0
|
| 3 |
+
pydantic==2.10.4
|
| 4 |
+
pydantic-settings==2.7.1
|
| 5 |
+
sse-starlette==2.2.1
|
| 6 |
+
httpx==0.28.1
|
| 7 |
+
python-multipart==0.0.20
|
| 8 |
+
duckduckgo-search>=5.0.0
|
| 9 |
+
jinja2>=3.0.0
|
| 10 |
+
pydantic-ai>=0.0.18
|
| 11 |
+
langgraph>=0.1.0
|
| 12 |
+
upstash-redis>=1.0.0
|
| 13 |
+
pyturso>=0.1.0
|
| 14 |
+
asyncpg>=0.29.0
|
| 15 |
+
neo4j>=5.20.0
|
| 16 |
+
boto3>=1.34.0
|
| 17 |
+
structlog>=24.0.0
|
| 18 |
+
google-generativeai>=0.8.0
|
routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Nancy routers package
|
routers/admin.py
ADDED
|
@@ -0,0 +1,831 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Admin Dashboard.
|
| 3 |
+
|
| 4 |
+
Exposes a beautiful, premium dark-mode, glassmorphic monitoring dashboard at /admin
|
| 5 |
+
for visualizing system health, queues, circuit breakers, and active sessions.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
import hashlib
|
| 13 |
+
from fastapi import APIRouter, Request, Body
|
| 14 |
+
from fastapi.responses import HTMLResponse
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
from core.queue import task_queue
|
| 18 |
+
from core.router import provider_router
|
| 19 |
+
from core.sessions import session_store
|
| 20 |
+
from core.redis_client import redis_client
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/admin", tags=["Nancy Administration"])
|
| 23 |
+
|
| 24 |
+
# HTML template string containing premium CSS styled with glassmorphic cards and gradients
|
| 25 |
+
DASHBOARD_HTML = """
|
| 26 |
+
<!DOCTYPE html>
|
| 27 |
+
<html lang="en">
|
| 28 |
+
<head>
|
| 29 |
+
<meta charset="UTF-8">
|
| 30 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 31 |
+
<title>Nancy v2 β Control Center</title>
|
| 32 |
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 33 |
+
<style>
|
| 34 |
+
:root {
|
| 35 |
+
--bg-dark: #090a0f;
|
| 36 |
+
--panel-bg: rgba(17, 19, 31, 0.7);
|
| 37 |
+
--border-glow: rgba(124, 58, 237, 0.25);
|
| 38 |
+
--primary: #8b5cf6;
|
| 39 |
+
--primary-glow: rgba(139, 92, 246, 0.4);
|
| 40 |
+
--accent: #06b6d4;
|
| 41 |
+
--accent-glow: rgba(6, 182, 212, 0.4);
|
| 42 |
+
--success: #10b981;
|
| 43 |
+
--danger: #ef4444;
|
| 44 |
+
--warning: #f59e0b;
|
| 45 |
+
--text-main: #f3f4f6;
|
| 46 |
+
--text-muted: #9ca3af;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
* {
|
| 50 |
+
margin: 0;
|
| 51 |
+
padding: 0;
|
| 52 |
+
box-sizing: border-box;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
body {
|
| 56 |
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
| 57 |
+
background-color: var(--bg-dark);
|
| 58 |
+
color: var(--text-main);
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
overflow-x: hidden;
|
| 61 |
+
background-image:
|
| 62 |
+
radial-gradient(circle at 10% 20%, rgba(139, 92, 246, 0.1) 0%, transparent 40%),
|
| 63 |
+
radial-gradient(circle at 90% 80%, rgba(6, 182, 212, 0.08) 0%, transparent 40%);
|
| 64 |
+
background-attachment: fixed;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* Container & Navigation */
|
| 68 |
+
.container {
|
| 69 |
+
max-width: 1300px;
|
| 70 |
+
margin: 0 auto;
|
| 71 |
+
padding: 2rem;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
header {
|
| 75 |
+
display: flex;
|
| 76 |
+
justify-content: space-between;
|
| 77 |
+
align-items: center;
|
| 78 |
+
margin-bottom: 2.5rem;
|
| 79 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 80 |
+
padding-bottom: 1.5rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.logo-section h1 {
|
| 84 |
+
font-size: 1.8rem;
|
| 85 |
+
font-weight: 700;
|
| 86 |
+
background: linear-gradient(135deg, #a78bfa 0%, #22d3ee 100%);
|
| 87 |
+
-webkit-background-clip: text;
|
| 88 |
+
-webkit-text-fill-color: transparent;
|
| 89 |
+
letter-spacing: -0.5px;
|
| 90 |
+
display: flex;
|
| 91 |
+
align-items: center;
|
| 92 |
+
gap: 0.5rem;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.logo-section h1::before {
|
| 96 |
+
content: '';
|
| 97 |
+
display: inline-block;
|
| 98 |
+
width: 12px;
|
| 99 |
+
height: 12px;
|
| 100 |
+
background: #a78bfa;
|
| 101 |
+
border-radius: 3px;
|
| 102 |
+
box-shadow: 0 0 10px #a78bfa;
|
| 103 |
+
animation: pulse-glow 2s infinite;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.logo-section p {
|
| 107 |
+
font-size: 0.85rem;
|
| 108 |
+
color: var(--text-muted);
|
| 109 |
+
margin-top: 0.2rem;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.sys-time {
|
| 113 |
+
font-size: 0.9rem;
|
| 114 |
+
color: var(--text-muted);
|
| 115 |
+
background: rgba(255, 255, 255, 0.03);
|
| 116 |
+
padding: 0.5rem 1rem;
|
| 117 |
+
border-radius: 99px;
|
| 118 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* Stats Grid */
|
| 122 |
+
.stats-grid {
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 125 |
+
gap: 1.5rem;
|
| 126 |
+
margin-bottom: 2rem;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.stat-card {
|
| 130 |
+
background: var(--panel-bg);
|
| 131 |
+
border-radius: 16px;
|
| 132 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 133 |
+
padding: 1.5rem;
|
| 134 |
+
position: relative;
|
| 135 |
+
overflow: hidden;
|
| 136 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 137 |
+
backdrop-filter: blur(10px);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.stat-card::before {
|
| 141 |
+
content: '';
|
| 142 |
+
position: absolute;
|
| 143 |
+
top: 0;
|
| 144 |
+
left: 0;
|
| 145 |
+
right: 0;
|
| 146 |
+
height: 2px;
|
| 147 |
+
background: linear-gradient(90deg, transparent, var(--primary-glow), transparent);
|
| 148 |
+
opacity: 0;
|
| 149 |
+
transition: opacity 0.3s ease;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.stat-card:hover {
|
| 153 |
+
transform: translateY(-4px);
|
| 154 |
+
border-color: var(--border-glow);
|
| 155 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.stat-card:hover::before {
|
| 159 |
+
opacity: 1;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.stat-header {
|
| 163 |
+
display: flex;
|
| 164 |
+
justify-content: space-between;
|
| 165 |
+
align-items: center;
|
| 166 |
+
color: var(--text-muted);
|
| 167 |
+
font-size: 0.85rem;
|
| 168 |
+
font-weight: 500;
|
| 169 |
+
text-transform: uppercase;
|
| 170 |
+
letter-spacing: 0.5px;
|
| 171 |
+
margin-bottom: 1rem;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.stat-val {
|
| 175 |
+
font-size: 2.2rem;
|
| 176 |
+
font-weight: 700;
|
| 177 |
+
color: var(--text-main);
|
| 178 |
+
letter-spacing: -1px;
|
| 179 |
+
line-height: 1;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.stat-sub {
|
| 183 |
+
font-size: 0.8rem;
|
| 184 |
+
margin-top: 0.6rem;
|
| 185 |
+
display: flex;
|
| 186 |
+
align-items: center;
|
| 187 |
+
gap: 0.4rem;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/* Circuit Breakers & Health Panel */
|
| 191 |
+
.main-layout {
|
| 192 |
+
display: grid;
|
| 193 |
+
grid-template-columns: 2fr 1fr;
|
| 194 |
+
gap: 1.5rem;
|
| 195 |
+
margin-bottom: 2rem;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@media (max-width: 968px) {
|
| 199 |
+
.main-layout {
|
| 200 |
+
grid-template-columns: 1fr;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.panel {
|
| 205 |
+
background: var(--panel-bg);
|
| 206 |
+
border-radius: 20px;
|
| 207 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 208 |
+
padding: 1.8rem;
|
| 209 |
+
backdrop-filter: blur(10px);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.panel-title {
|
| 213 |
+
font-size: 1.2rem;
|
| 214 |
+
font-weight: 600;
|
| 215 |
+
margin-bottom: 1.5rem;
|
| 216 |
+
display: flex;
|
| 217 |
+
justify-content: space-between;
|
| 218 |
+
align-items: center;
|
| 219 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 220 |
+
padding-bottom: 0.8rem;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
/* Provider Tables & Lists */
|
| 224 |
+
.provider-list {
|
| 225 |
+
display: flex;
|
| 226 |
+
flex-direction: column;
|
| 227 |
+
gap: 1rem;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.provider-row {
|
| 231 |
+
display: flex;
|
| 232 |
+
justify-content: space-between;
|
| 233 |
+
align-items: center;
|
| 234 |
+
padding: 1rem 1.2rem;
|
| 235 |
+
background: rgba(255, 255, 255, 0.02);
|
| 236 |
+
border: 1px solid rgba(255, 255, 255, 0.03);
|
| 237 |
+
border-radius: 12px;
|
| 238 |
+
transition: background 0.2s ease;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.provider-row:hover {
|
| 242 |
+
background: rgba(255, 255, 255, 0.04);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.provider-info {
|
| 246 |
+
display: flex;
|
| 247 |
+
align-items: center;
|
| 248 |
+
gap: 1rem;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.provider-avatar {
|
| 252 |
+
width: 36px;
|
| 253 |
+
height: 36px;
|
| 254 |
+
border-radius: 8px;
|
| 255 |
+
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(6, 182, 212, 0.2));
|
| 256 |
+
display: flex;
|
| 257 |
+
align-items: center;
|
| 258 |
+
justify-content: center;
|
| 259 |
+
font-weight: 700;
|
| 260 |
+
color: var(--primary);
|
| 261 |
+
font-size: 0.9rem;
|
| 262 |
+
text-transform: uppercase;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.provider-details h3 {
|
| 266 |
+
font-size: 0.95rem;
|
| 267 |
+
font-weight: 600;
|
| 268 |
+
margin-bottom: 0.15rem;
|
| 269 |
+
text-transform: capitalize;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.provider-details span {
|
| 273 |
+
font-size: 0.75rem;
|
| 274 |
+
color: var(--text-muted);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.status-badge {
|
| 278 |
+
padding: 0.35rem 0.75rem;
|
| 279 |
+
border-radius: 99px;
|
| 280 |
+
font-size: 0.75rem;
|
| 281 |
+
font-weight: 600;
|
| 282 |
+
text-transform: uppercase;
|
| 283 |
+
letter-spacing: 0.5px;
|
| 284 |
+
display: flex;
|
| 285 |
+
align-items: center;
|
| 286 |
+
gap: 0.3rem;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.status-healthy {
|
| 290 |
+
background: rgba(16, 185, 129, 0.1);
|
| 291 |
+
color: var(--success);
|
| 292 |
+
border: 1px solid rgba(16, 185, 129, 0.2);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.status-degraded {
|
| 296 |
+
background: rgba(245, 158, 11, 0.1);
|
| 297 |
+
color: var(--warning);
|
| 298 |
+
border: 1px solid rgba(245, 158, 11, 0.2);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.status-broken {
|
| 302 |
+
background: rgba(239, 68, 68, 0.1);
|
| 303 |
+
color: var(--danger);
|
| 304 |
+
border: 1px solid rgba(239, 68, 68, 0.2);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/* Sessions list */
|
| 308 |
+
.session-item {
|
| 309 |
+
padding: 1rem;
|
| 310 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
| 311 |
+
display: flex;
|
| 312 |
+
justify-content: space-between;
|
| 313 |
+
align-items: center;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.session-item:last-child {
|
| 317 |
+
border-bottom: none;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.session-meta h4 {
|
| 321 |
+
font-size: 0.9rem;
|
| 322 |
+
font-weight: 600;
|
| 323 |
+
margin-bottom: 0.2rem;
|
| 324 |
+
color: var(--text-main);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.session-meta p {
|
| 328 |
+
font-size: 0.75rem;
|
| 329 |
+
color: var(--text-muted);
|
| 330 |
+
white-space: nowrap;
|
| 331 |
+
overflow: hidden;
|
| 332 |
+
text-overflow: ellipsis;
|
| 333 |
+
max-width: 250px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.badge {
|
| 337 |
+
background: rgba(255, 255, 255, 0.05);
|
| 338 |
+
padding: 0.25rem 0.5rem;
|
| 339 |
+
border-radius: 6px;
|
| 340 |
+
font-size: 0.7rem;
|
| 341 |
+
color: var(--text-muted);
|
| 342 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
/* Animations */
|
| 346 |
+
@keyframes pulse-glow {
|
| 347 |
+
0%, 100% {
|
| 348 |
+
transform: scale(1);
|
| 349 |
+
box-shadow: 0 0 10px #a78bfa;
|
| 350 |
+
}
|
| 351 |
+
50% {
|
| 352 |
+
transform: scale(1.15);
|
| 353 |
+
box-shadow: 0 0 18px #a78bfa, 0 0 5px #22d3ee;
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.pulse-dot {
|
| 358 |
+
width: 8px;
|
| 359 |
+
height: 8px;
|
| 360 |
+
border-radius: 50%;
|
| 361 |
+
display: inline-block;
|
| 362 |
+
}
|
| 363 |
+
.pulse-dot.active {
|
| 364 |
+
background-color: var(--success);
|
| 365 |
+
box-shadow: 0 0 8px var(--success);
|
| 366 |
+
animation: pulse-active 1.5s infinite;
|
| 367 |
+
}
|
| 368 |
+
@keyframes pulse-active {
|
| 369 |
+
0% { transform: scale(0.9); opacity: 1; }
|
| 370 |
+
50% { transform: scale(1.2); opacity: 0.7; }
|
| 371 |
+
100% { transform: scale(0.9); opacity: 1; }
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.refresh-btn {
|
| 375 |
+
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
| 376 |
+
border: none;
|
| 377 |
+
color: white;
|
| 378 |
+
padding: 0.5rem 1.2rem;
|
| 379 |
+
border-radius: 8px;
|
| 380 |
+
font-size: 0.85rem;
|
| 381 |
+
font-weight: 600;
|
| 382 |
+
cursor: pointer;
|
| 383 |
+
transition: opacity 0.2s ease;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.refresh-btn:hover {
|
| 387 |
+
opacity: 0.9;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
/* Dynamic API Key Vault UI Styles */
|
| 391 |
+
.revoke-btn {
|
| 392 |
+
background: rgba(239, 68, 68, 0.15);
|
| 393 |
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
| 394 |
+
color: var(--danger);
|
| 395 |
+
padding: 0.35rem 0.8rem;
|
| 396 |
+
border-radius: 6px;
|
| 397 |
+
font-size: 0.75rem;
|
| 398 |
+
font-weight: 600;
|
| 399 |
+
cursor: pointer;
|
| 400 |
+
transition: all 0.2s ease;
|
| 401 |
+
}
|
| 402 |
+
.revoke-btn:hover {
|
| 403 |
+
background: var(--danger);
|
| 404 |
+
color: white;
|
| 405 |
+
box-shadow: 0 0 10px rgba(239, 68, 68, 0.4);
|
| 406 |
+
}
|
| 407 |
+
.generate-panel {
|
| 408 |
+
display: flex;
|
| 409 |
+
gap: 1rem;
|
| 410 |
+
margin-top: 1rem;
|
| 411 |
+
align-items: center;
|
| 412 |
+
}
|
| 413 |
+
.api-input {
|
| 414 |
+
flex: 1;
|
| 415 |
+
background: rgba(0, 0, 0, 0.3);
|
| 416 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 417 |
+
border-radius: 8px;
|
| 418 |
+
padding: 0.6rem 1rem;
|
| 419 |
+
color: white;
|
| 420 |
+
font-family: inherit;
|
| 421 |
+
font-size: 0.9rem;
|
| 422 |
+
outline: none;
|
| 423 |
+
transition: border-color 0.2s;
|
| 424 |
+
}
|
| 425 |
+
.api-input:focus {
|
| 426 |
+
border-color: var(--primary);
|
| 427 |
+
}
|
| 428 |
+
.api-table {
|
| 429 |
+
width: 100%;
|
| 430 |
+
border-collapse: collapse;
|
| 431 |
+
margin-top: 1rem;
|
| 432 |
+
}
|
| 433 |
+
.api-table th {
|
| 434 |
+
text-align: left;
|
| 435 |
+
padding: 0.8rem 1rem;
|
| 436 |
+
color: var(--text-muted);
|
| 437 |
+
font-size: 0.8rem;
|
| 438 |
+
font-weight: 600;
|
| 439 |
+
text-transform: uppercase;
|
| 440 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
| 441 |
+
}
|
| 442 |
+
.copy-btn {
|
| 443 |
+
background: rgba(255, 255, 255, 0.08);
|
| 444 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 445 |
+
color: white;
|
| 446 |
+
padding: 0.6rem 1.2rem;
|
| 447 |
+
border-radius: 8px;
|
| 448 |
+
font-weight: 600;
|
| 449 |
+
cursor: pointer;
|
| 450 |
+
transition: all 0.2s;
|
| 451 |
+
}
|
| 452 |
+
.copy-btn:hover {
|
| 453 |
+
background: rgba(255, 255, 255, 0.15);
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
</style>
|
| 457 |
+
</head>
|
| 458 |
+
<body>
|
| 459 |
+
<div class="container">
|
| 460 |
+
<header>
|
| 461 |
+
<div class="logo-section">
|
| 462 |
+
<h1>NANCY CONTROL</h1>
|
| 463 |
+
<p>Advanced Free Chatbot Orchestrator & API Router</p>
|
| 464 |
+
</div>
|
| 465 |
+
<div class="sys-time">
|
| 466 |
+
SYS STATE: <span style="color: var(--success); font-weight:600;">OPERATIONAL</span>
|
| 467 |
+
</div>
|
| 468 |
+
</header>
|
| 469 |
+
|
| 470 |
+
<!-- Stats row -->
|
| 471 |
+
<div class="stats-grid">
|
| 472 |
+
<div class="stat-card">
|
| 473 |
+
<div class="stat-header">
|
| 474 |
+
<span>Task Queue Depth</span>
|
| 475 |
+
<span class="badge">SSE Queue</span>
|
| 476 |
+
</div>
|
| 477 |
+
<div class="stat-val">{queue_depth}</div>
|
| 478 |
+
<div class="stat-sub" style="color: var(--text-muted);">
|
| 479 |
+
<span class="pulse-dot active"></span> Active tasks waiting for pickup
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
<div class="stat-card">
|
| 484 |
+
<div class="stat-header">
|
| 485 |
+
<span>Active Sessions</span>
|
| 486 |
+
<span class="badge">Redis</span>
|
| 487 |
+
</div>
|
| 488 |
+
<div class="stat-val">{session_count}</div>
|
| 489 |
+
<div class="stat-sub" style="color: var(--text-muted);">
|
| 490 |
+
Tracked multi-conversation sessions
|
| 491 |
+
</div>
|
| 492 |
+
</div>
|
| 493 |
+
|
| 494 |
+
<div class="stat-card">
|
| 495 |
+
<div class="stat-header">
|
| 496 |
+
<span>Extension Hub</span>
|
| 497 |
+
<span class="badge">Relay Status</span>
|
| 498 |
+
</div>
|
| 499 |
+
<div class="stat-val" style="color: var(--success);">{ext_status}</div>
|
| 500 |
+
<div class="stat-sub" style="color: var(--text-muted);">
|
| 501 |
+
Clients connected in real-time
|
| 502 |
+
</div>
|
| 503 |
+
</div>
|
| 504 |
+
|
| 505 |
+
<div class="stat-card">
|
| 506 |
+
<div class="stat-header">
|
| 507 |
+
<span>Upstash Persistence</span>
|
| 508 |
+
<span class="badge">Cache</span>
|
| 509 |
+
</div>
|
| 510 |
+
<div class="stat-val" style="color: {redis_color}; font-size: 1.8rem; margin-top: 0.3rem;">{redis_status}</div>
|
| 511 |
+
<div class="stat-sub" style="color: var(--text-muted);">
|
| 512 |
+
State persistence engine status
|
| 513 |
+
</div>
|
| 514 |
+
</div>
|
| 515 |
+
</div>
|
| 516 |
+
|
| 517 |
+
<div class="main-layout">
|
| 518 |
+
<!-- Left panel: Health & Providers -->
|
| 519 |
+
<div class="panel">
|
| 520 |
+
<div class="panel-title">
|
| 521 |
+
<span>Provider Status & Failover Configuration</span>
|
| 522 |
+
<button class="refresh-btn" onclick="location.reload()">REFRESH STATE</button>
|
| 523 |
+
</div>
|
| 524 |
+
<div class="provider-list">
|
| 525 |
+
{provider_rows}
|
| 526 |
+
</div>
|
| 527 |
+
</div>
|
| 528 |
+
|
| 529 |
+
<!-- Right panel: Active sessions list -->
|
| 530 |
+
<div class="panel">
|
| 531 |
+
<div class="panel-title">
|
| 532 |
+
<span>Active Sessions</span>
|
| 533 |
+
<span class="badge">{session_count} Total</span>
|
| 534 |
+
</div>
|
| 535 |
+
<div style="max-height: 400px; overflow-y: auto;">
|
| 536 |
+
{session_rows}
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
|
| 541 |
+
<!-- π Dynamic API Key Vault Panel -->
|
| 542 |
+
<div class="panel" style="margin-top: 1.5rem;">
|
| 543 |
+
<div class="panel-title">
|
| 544 |
+
<span>π Dynamic API Key Handoff Vault</span>
|
| 545 |
+
<span class="badge">Redis Hashed Credentials</span>
|
| 546 |
+
</div>
|
| 547 |
+
|
| 548 |
+
<div style="margin-bottom: 1.5rem; background: rgba(139, 92, 246, 0.05); border: 1px solid rgba(139, 92, 246, 0.1); border-radius: 12px; padding: 1.2rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
| 549 |
+
<h4 style="color: #a78bfa; font-size: 0.95rem; font-weight: 600;">How to connect Ultron Swarm (or external agents)</h4>
|
| 550 |
+
<p style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.4;">
|
| 551 |
+
Generate a secure API key below. Paste it along with your Nancy server URL (e.g. <code>https://ghostdriveg1-free-llm-router.hf.space</code>) into the Ultron Swarm Control Dashboard. Nancy hashes and saves all keys securely using SHA-256 in Upstash Redis.
|
| 552 |
+
</p>
|
| 553 |
+
</div>
|
| 554 |
+
|
| 555 |
+
<!-- New Key Generator -->
|
| 556 |
+
<div class="generate-panel">
|
| 557 |
+
<input type="text" id="key-desc" class="api-input" placeholder="e.g. Ultron Swarm Production Client" />
|
| 558 |
+
<button class="refresh-btn" onclick="generateKey()">GENERATE ACCESS KEY</button>
|
| 559 |
+
</div>
|
| 560 |
+
|
| 561 |
+
<!-- Plaintext Key Display (Shown once on generation) -->
|
| 562 |
+
<div id="key-result-container" style="display: none; margin-top: 1.2rem; padding: 1.2rem; background: rgba(16, 185, 129, 0.06); border: 1px solid rgba(16, 185, 129, 0.15); border-radius: 12px;">
|
| 563 |
+
<h4 style="color: var(--success); font-size: 0.9rem; font-weight: 600; margin-bottom: 0.5rem;">Access Key Generated Successfully!</h4>
|
| 564 |
+
<p style="color: var(--text-muted); font-size: 0.8rem; margin-bottom: 0.8rem;">
|
| 565 |
+
β οΈ Copy this key now! For security reasons, you will <strong>NOT</strong> be able to view this plaintext key again.
|
| 566 |
+
</p>
|
| 567 |
+
<div style="display: flex; gap: 0.8rem; align-items: center;">
|
| 568 |
+
<input type="text" id="generated-key-display" class="api-input" readonly style="font-family: monospace; font-size: 0.95rem; color: var(--success); border-color: rgba(16, 185, 129, 0.25);" />
|
| 569 |
+
<button id="copy-btn" class="copy-btn" onclick="copyToClipboard()">COPY KEY</button>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
|
| 573 |
+
<!-- API Keys List Table -->
|
| 574 |
+
<div style="overflow-x: auto; margin-top: 1.5rem;">
|
| 575 |
+
<table class="api-table">
|
| 576 |
+
<thead>
|
| 577 |
+
<tr>
|
| 578 |
+
<th>Client Description</th>
|
| 579 |
+
<th>Key Hash (SHA-256)</th>
|
| 580 |
+
<th>Created At</th>
|
| 581 |
+
<th>Last Active</th>
|
| 582 |
+
<th style="text-align: right;">Action</th>
|
| 583 |
+
</tr>
|
| 584 |
+
</thead>
|
| 585 |
+
<tbody id="keys-tbody">
|
| 586 |
+
<tr>
|
| 587 |
+
<td colspan="5" style="text-align: center; padding: 1.5rem; color: var(--text-muted);">
|
| 588 |
+
Loading API keys vault...
|
| 589 |
+
</td>
|
| 590 |
+
</tr>
|
| 591 |
+
</tbody>
|
| 592 |
+
</table>
|
| 593 |
+
</div>
|
| 594 |
+
</div>
|
| 595 |
+
</div>
|
| 596 |
+
|
| 597 |
+
<!-- Frontend AJAX Scripting -->
|
| 598 |
+
<script>
|
| 599 |
+
async function loadKeys() {
|
| 600 |
+
try {
|
| 601 |
+
const resp = await fetch("/admin/keys/list");
|
| 602 |
+
const keys = await resp.json();
|
| 603 |
+
const tbody = document.getElementById("keys-tbody");
|
| 604 |
+
if (!keys || keys.length === 0) {
|
| 605 |
+
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 1.5rem; color: var(--text-muted);">No dynamic API keys found. Generate one above!</td></tr>';
|
| 606 |
+
return;
|
| 607 |
+
}
|
| 608 |
+
tbody.innerHTML = keys.map(k => {
|
| 609 |
+
const date = new Date(k.created_at * 1000).toLocaleString();
|
| 610 |
+
const lastUsed = k.last_used ? new Date(k.last_used * 1000).toLocaleString() : "Never";
|
| 611 |
+
return `
|
| 612 |
+
<tr>
|
| 613 |
+
<td style="padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.04); font-weight: 500;">${escapeHtml(k.description)}</td>
|
| 614 |
+
<td style="padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.04); font-family: monospace; color: var(--accent);">${k.hash.substring(0, 12)}...</td>
|
| 615 |
+
<td style="padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text-muted); font-size: 0.85rem;">${date}</td>
|
| 616 |
+
<td style="padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text-muted); font-size: 0.85rem;">${lastUsed}</td>
|
| 617 |
+
<td style="padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.04); text-align: right;">
|
| 618 |
+
<button class="revoke-btn" onclick="revokeKey('${k.hash}')">REVOKE</button>
|
| 619 |
+
</td>
|
| 620 |
+
</tr>
|
| 621 |
+
`;
|
| 622 |
+
}).join('');
|
| 623 |
+
} catch (e) {
|
| 624 |
+
console.error("Error loading keys:", e);
|
| 625 |
+
}
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
function escapeHtml(str) {
|
| 629 |
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
async function generateKey() {
|
| 633 |
+
const desc = document.getElementById("key-desc").value.trim() || "Swarm Client";
|
| 634 |
+
try {
|
| 635 |
+
const resp = await fetch("/admin/keys/create", {
|
| 636 |
+
method: "POST",
|
| 637 |
+
headers: { "Content-Type": "application/json" },
|
| 638 |
+
body: JSON.stringify({ description: desc })
|
| 639 |
+
});
|
| 640 |
+
const data = await resp.json();
|
| 641 |
+
|
| 642 |
+
// Show generated key container
|
| 643 |
+
const resultDiv = document.getElementById("key-result-container");
|
| 644 |
+
resultDiv.style.display = "block";
|
| 645 |
+
document.getElementById("generated-key-display").value = data.plaintext_key;
|
| 646 |
+
|
| 647 |
+
document.getElementById("key-desc").value = "";
|
| 648 |
+
await loadKeys();
|
| 649 |
+
} catch (e) {
|
| 650 |
+
alert("Error generating key: " + e);
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
async function revokeKey(hash) {
|
| 655 |
+
if (!confirm("Are you sure you want to revoke this API key? Connected systems using this key will immediately lose access!")) return;
|
| 656 |
+
try {
|
| 657 |
+
await fetch(`/admin/keys/revoke/${hash}`, { method: "DELETE" });
|
| 658 |
+
await loadKeys();
|
| 659 |
+
} catch (e) {
|
| 660 |
+
alert("Error revoking key: " + e);
|
| 661 |
+
}
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function copyToClipboard() {
|
| 665 |
+
const copyText = document.getElementById("generated-key-display");
|
| 666 |
+
copyText.select();
|
| 667 |
+
copyText.setSelectionRange(0, 99999);
|
| 668 |
+
navigator.clipboard.writeText(copyText.value);
|
| 669 |
+
|
| 670 |
+
const copyBtn = document.getElementById("copy-btn");
|
| 671 |
+
copyBtn.innerText = "COPIED!";
|
| 672 |
+
copyBtn.style.background = "var(--success)";
|
| 673 |
+
setTimeout(() => {
|
| 674 |
+
copyBtn.innerText = "COPY KEY";
|
| 675 |
+
copyBtn.style.background = "rgba(255,255,255,0.08)";
|
| 676 |
+
}, 2000);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
// Auto-run on startup
|
| 680 |
+
document.addEventListener("DOMContentLoaded", loadKeys);
|
| 681 |
+
</script>
|
| 682 |
+
</body>
|
| 683 |
+
</html>
|
| 684 |
+
|
| 685 |
+
"""
|
| 686 |
+
|
| 687 |
+
@router.get("/", response_class=HTMLResponse)
|
| 688 |
+
async def admin_dashboard(request: Request):
|
| 689 |
+
"""Renders the control center admin status page."""
|
| 690 |
+
|
| 691 |
+
# 1. Fetch current queue size
|
| 692 |
+
try:
|
| 693 |
+
queue_depth = len(task_queue.queue)
|
| 694 |
+
except Exception:
|
| 695 |
+
queue_depth = 0
|
| 696 |
+
|
| 697 |
+
# 2. Check Extension SSE connections status
|
| 698 |
+
ext_connected = task_queue.is_extension_active()
|
| 699 |
+
ext_status = "Connected" if ext_connected else "Offline"
|
| 700 |
+
|
| 701 |
+
# 3. Check Redis Connection Status
|
| 702 |
+
redis_active = redis_client.is_enabled
|
| 703 |
+
redis_status = "ONLINE" if redis_active else "FALLBACK (IN-MEMORY)"
|
| 704 |
+
redis_color = "var(--success)" if redis_active else "var(--warning)"
|
| 705 |
+
|
| 706 |
+
# 4. Fetch Sessions list
|
| 707 |
+
try:
|
| 708 |
+
sessions = await session_store.list_sessions()
|
| 709 |
+
session_count = len(sessions)
|
| 710 |
+
except Exception:
|
| 711 |
+
sessions = []
|
| 712 |
+
session_count = 0
|
| 713 |
+
|
| 714 |
+
# 5. Build dynamic session items
|
| 715 |
+
session_rows = ""
|
| 716 |
+
if not sessions:
|
| 717 |
+
session_rows = '<div style="padding: 1.5rem; text-align: center; color: var(--text-muted); font-size: 0.85rem;">No active tracked sessions found.</div>'
|
| 718 |
+
else:
|
| 719 |
+
for sess in sessions[:8]: # Show top 8 active
|
| 720 |
+
session_rows += f"""
|
| 721 |
+
<div class="session-item">
|
| 722 |
+
<div class="session-meta">
|
| 723 |
+
<h4>{sess.title}</h4>
|
| 724 |
+
<p>{sess.conversation_url or 'Fresh Chat (No URL yet)'}</p>
|
| 725 |
+
</div>
|
| 726 |
+
<span class="badge" style="text-transform: capitalize;">{sess.provider}</span>
|
| 727 |
+
</div>
|
| 728 |
+
"""
|
| 729 |
+
|
| 730 |
+
# 6. Fetch Providers circuit breaker states
|
| 731 |
+
# Default list of providers
|
| 732 |
+
all_providers = ["chatgpt", "gemini", "deepseek", "kimi", "claude", "nim", "zai"]
|
| 733 |
+
provider_rows = ""
|
| 734 |
+
|
| 735 |
+
for provider in all_providers:
|
| 736 |
+
# Determine status
|
| 737 |
+
is_healthy = provider_router.is_provider_healthy(provider)
|
| 738 |
+
|
| 739 |
+
badge_class = "status-healthy" if is_healthy else "status-broken"
|
| 740 |
+
status_text = "HEALTHY" if is_healthy else "DEGRADED / DRAINED"
|
| 741 |
+
|
| 742 |
+
# Determine adapter fallback priority indicator
|
| 743 |
+
fallback_pos = "Primary" if provider in provider_router.fallback_chain else "Bypass / API"
|
| 744 |
+
if provider in provider_router.fallback_chain:
|
| 745 |
+
idx = provider_router.fallback_chain.index(provider) + 1
|
| 746 |
+
fallback_pos = f"Fallback Chain #{idx}"
|
| 747 |
+
|
| 748 |
+
provider_rows += f"""
|
| 749 |
+
<div class="provider-row">
|
| 750 |
+
<div class="provider-info">
|
| 751 |
+
<div class="provider-avatar">{provider[:2]}</div>
|
| 752 |
+
<div class="provider-details">
|
| 753 |
+
<h3>{provider}</h3>
|
| 754 |
+
<span>Priority: {fallback_pos}</span>
|
| 755 |
+
</div>
|
| 756 |
+
</div>
|
| 757 |
+
<div class="status-badge {badge_class}">
|
| 758 |
+
<span class="pulse-dot active" style="background-color: currentColor;"></span>
|
| 759 |
+
{status_text}
|
| 760 |
+
</div>
|
| 761 |
+
</div>
|
| 762 |
+
"""
|
| 763 |
+
|
| 764 |
+
# Render dashboard
|
| 765 |
+
rendered = DASHBOARD_HTML.format(
|
| 766 |
+
queue_depth=queue_depth,
|
| 767 |
+
session_count=session_count,
|
| 768 |
+
ext_status=ext_status,
|
| 769 |
+
redis_status=redis_status,
|
| 770 |
+
redis_color=redis_color,
|
| 771 |
+
session_rows=session_rows,
|
| 772 |
+
provider_rows=provider_rows
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
return HTMLResponse(content=rendered)
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
# ββ Dynamic API Key AJAX Endpoints βββββββββββββββββββββββββββββββββββββββββββ
|
| 779 |
+
|
| 780 |
+
@router.post("/keys/create")
|
| 781 |
+
async def create_api_key(payload: dict = Body(default={})):
|
| 782 |
+
"""Generates a secure UUID API key prefixed with ny_, hashes it, and caches in Redis."""
|
| 783 |
+
description = payload.get("description", "Swarm Integration Client")
|
| 784 |
+
plaintext_uuid = uuid.uuid4().hex
|
| 785 |
+
plaintext_key = f"ny_{plaintext_uuid}"
|
| 786 |
+
hashed = hashlib.sha256(plaintext_key.encode("utf-8")).hexdigest()
|
| 787 |
+
|
| 788 |
+
key_id = str(uuid.uuid4())
|
| 789 |
+
metadata = {
|
| 790 |
+
"id": key_id,
|
| 791 |
+
"description": description,
|
| 792 |
+
"hash": hashed,
|
| 793 |
+
"created_at": int(time.time()),
|
| 794 |
+
"last_used": None,
|
| 795 |
+
"request_count": 0
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
# Save key metadata and register in active hashes set
|
| 799 |
+
await redis_client.set_json(f"nancy:api_keys:{hashed}", metadata)
|
| 800 |
+
await redis_client._execute("SADD", "nancy:active_key_hashes", hashed)
|
| 801 |
+
|
| 802 |
+
return {
|
| 803 |
+
"key_id": key_id,
|
| 804 |
+
"plaintext_key": plaintext_key,
|
| 805 |
+
"description": description,
|
| 806 |
+
"created_at": metadata["created_at"]
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
@router.get("/keys/list")
|
| 811 |
+
async def list_api_keys():
|
| 812 |
+
"""Lists metadata for all active dynamic API keys."""
|
| 813 |
+
try:
|
| 814 |
+
hashes = await redis_client._execute("SMEMBERS", "nancy:active_key_hashes") or []
|
| 815 |
+
keys_list = []
|
| 816 |
+
for h in hashes:
|
| 817 |
+
meta = await redis_client.get_json(f"nancy:api_keys:{h}")
|
| 818 |
+
if meta:
|
| 819 |
+
keys_list.append(meta)
|
| 820 |
+
return sorted(keys_list, key=lambda x: x.get("created_at", 0), reverse=True)
|
| 821 |
+
except Exception:
|
| 822 |
+
return []
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
@router.delete("/keys/revoke/{hashed_key}")
|
| 826 |
+
async def revoke_api_key(hashed_key: str):
|
| 827 |
+
"""Revokes and deletes an API key using its SHA-256 hash."""
|
| 828 |
+
await redis_client.delete(f"nancy:api_keys:{hashed_key}")
|
| 829 |
+
await redis_client._execute("SREM", "nancy:active_key_hashes", hashed_key)
|
| 830 |
+
return {"success": True, "message": "API key revoked successfully."}
|
| 831 |
+
|
routers/api.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β OpenAI-Compatible API Router.
|
| 3 |
+
|
| 4 |
+
Provides standard chat completion and models endpoints matching the OpenAI spec.
|
| 5 |
+
This allows any OpenAI-compatible client (e.g. LiteLLM, langchain, openai SDK)
|
| 6 |
+
to use Nancy as a drop-in replacement backbone.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import logging
|
| 13 |
+
import time
|
| 14 |
+
from typing import AsyncGenerator
|
| 15 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 16 |
+
from fastapi.responses import JSONResponse
|
| 17 |
+
from sse_starlette.sse import EventSourceResponse
|
| 18 |
+
|
| 19 |
+
from config import settings
|
| 20 |
+
from core.auth import require_api_key
|
| 21 |
+
from core.queue import task_queue
|
| 22 |
+
from core.router import provider_router
|
| 23 |
+
from models.openai import (
|
| 24 |
+
ChatCompletionChunk,
|
| 25 |
+
ChatCompletionRequest,
|
| 26 |
+
ChatCompletionResponse,
|
| 27 |
+
ErrorDetail,
|
| 28 |
+
ErrorResponse,
|
| 29 |
+
ModelInfo,
|
| 30 |
+
ModelListResponse,
|
| 31 |
+
)
|
| 32 |
+
from models.task import Task, TaskStatus
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger("nancy.api")
|
| 35 |
+
|
| 36 |
+
def parse_tool_call_json(text: str) -> list[dict] | None:
|
| 37 |
+
import json
|
| 38 |
+
import re
|
| 39 |
+
import uuid
|
| 40 |
+
text = text.strip()
|
| 41 |
+
|
| 42 |
+
# Resilient check for CALL: tool_name(...) format
|
| 43 |
+
if "CALL:" in text:
|
| 44 |
+
match = re.search(r"CALL:\s*(\w+)\((.*?)\)", text, re.DOTALL)
|
| 45 |
+
if match:
|
| 46 |
+
func_name = match.group(1)
|
| 47 |
+
args_content = match.group(2)
|
| 48 |
+
|
| 49 |
+
# Parse arguments in key="value" or key=value format
|
| 50 |
+
args = {}
|
| 51 |
+
arg_matches = re.findall(r"(\w+)\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s,]+))", args_content)
|
| 52 |
+
for key, val1, val2, val3 in arg_matches:
|
| 53 |
+
val = val1 or val2 or val3
|
| 54 |
+
val_strip = val.strip()
|
| 55 |
+
if val_strip.lower() == "true":
|
| 56 |
+
val = True
|
| 57 |
+
elif val_strip.lower() == "false":
|
| 58 |
+
val = False
|
| 59 |
+
else:
|
| 60 |
+
try:
|
| 61 |
+
if "." in val_strip:
|
| 62 |
+
val = float(val_strip)
|
| 63 |
+
else:
|
| 64 |
+
val = int(val_strip)
|
| 65 |
+
except:
|
| 66 |
+
pass
|
| 67 |
+
args[key] = val
|
| 68 |
+
|
| 69 |
+
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
| 70 |
+
return [{
|
| 71 |
+
"id": call_id,
|
| 72 |
+
"type": "function",
|
| 73 |
+
"function": {
|
| 74 |
+
"name": func_name,
|
| 75 |
+
"arguments": json.dumps(args)
|
| 76 |
+
}
|
| 77 |
+
}]
|
| 78 |
+
|
| 79 |
+
# Fallback to standard Markdown/JSON block parser
|
| 80 |
+
if text.startswith("```"):
|
| 81 |
+
match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
|
| 82 |
+
if match:
|
| 83 |
+
text = match.group(1).strip()
|
| 84 |
+
|
| 85 |
+
if not (text.startswith("{") and "tool_calls" in text):
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
data = json.loads(text)
|
| 90 |
+
if "tool_calls" in data and isinstance(data["tool_calls"], list):
|
| 91 |
+
validated = []
|
| 92 |
+
for tc in data["tool_calls"]:
|
| 93 |
+
if "name" in tc or ("function" in tc and "name" in tc["function"]):
|
| 94 |
+
func_name = tc.get("name") or tc["function"].get("name")
|
| 95 |
+
args = tc.get("arguments") or tc["function"].get("arguments", {})
|
| 96 |
+
if isinstance(args, str):
|
| 97 |
+
try:
|
| 98 |
+
args = json.loads(args)
|
| 99 |
+
except:
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
call_id = tc.get("id") or f"call_{uuid.uuid4().hex[:12]}"
|
| 103 |
+
validated.append({
|
| 104 |
+
"id": call_id,
|
| 105 |
+
"type": tc.get("type", "function"),
|
| 106 |
+
"function": {
|
| 107 |
+
"name": func_name,
|
| 108 |
+
"arguments": json.dumps(args) if isinstance(args, dict) else str(args)
|
| 109 |
+
}
|
| 110 |
+
})
|
| 111 |
+
if validated:
|
| 112 |
+
return validated
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.warning("Failed to parse potential tool call JSON: %s", e)
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
router = APIRouter(prefix="/v1", tags=["OpenAI Compatible API"])
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.post("/chat/completions", response_model=ChatCompletionResponse)
|
| 122 |
+
async def chat_completions(
|
| 123 |
+
request: ChatCompletionRequest,
|
| 124 |
+
api_key: str = Depends(require_api_key),
|
| 125 |
+
):
|
| 126 |
+
"""
|
| 127 |
+
OpenAI-Compatible Chat Completions Endpoint.
|
| 128 |
+
Receives prompt, selects available provider, enqueues task for extension,
|
| 129 |
+
and returns either a JSON response or an SSE stream.
|
| 130 |
+
"""
|
| 131 |
+
# 1. Resolve request model to canonical provider
|
| 132 |
+
requested_model = request.model
|
| 133 |
+
provider = provider_router.resolve(requested_model)
|
| 134 |
+
|
| 135 |
+
# Inject tool instructions into system prompt if requested
|
| 136 |
+
if request.tools:
|
| 137 |
+
# Build a clean, simplified human-readable tool definition list
|
| 138 |
+
tool_specs = []
|
| 139 |
+
for t in request.tools:
|
| 140 |
+
func = t.get("function", {})
|
| 141 |
+
name = func.get("name")
|
| 142 |
+
desc = func.get("description", "")
|
| 143 |
+
params = func.get("parameters", {}).get("properties", {})
|
| 144 |
+
param_list = ", ".join(f"{k}: {v.get('type')}" for k, v in params.items())
|
| 145 |
+
tool_specs.append(f"- {name}({param_list}): {desc}")
|
| 146 |
+
|
| 147 |
+
specs_str = "\n".join(tool_specs)
|
| 148 |
+
system_instruction = (
|
| 149 |
+
"You are a helpful assistant with access to the following server-side tools. "
|
| 150 |
+
"If you need to call a tool, you MUST respond ONLY with a clean tool execution instruction in this exact format:\n"
|
| 151 |
+
"CALL: tool_name(arg1=\"value1\", arg2=\"value2\")\n"
|
| 152 |
+
"and absolutely nothing else. Do not add any greeting, markdown formatting (like ```json), or explanatory text before or after the CALL. "
|
| 153 |
+
"If no tool is needed or you are answering with the tool result, respond with standard conversational text.\n\n"
|
| 154 |
+
"Here are the available tools:\n"
|
| 155 |
+
f"{specs_str}"
|
| 156 |
+
)
|
| 157 |
+
messages_dump = [msg.model_dump() for msg in request.messages]
|
| 158 |
+
if messages_dump and messages_dump[0]["role"] == "system":
|
| 159 |
+
messages_dump[0]["content"] = system_instruction + "\n\n" + (messages_dump[0]["content"] or "")
|
| 160 |
+
else:
|
| 161 |
+
messages_dump.insert(0, {"role": "system", "content": system_instruction})
|
| 162 |
+
else:
|
| 163 |
+
messages_dump = [msg.model_dump() for msg in request.messages]
|
| 164 |
+
|
| 165 |
+
# 2. Select available provider with routing / failover checks
|
| 166 |
+
selected_provider = provider_router.select_provider(provider)
|
| 167 |
+
if not selected_provider:
|
| 168 |
+
error_detail = ErrorDetail(
|
| 169 |
+
message="No healthy chatbot providers available at the moment.",
|
| 170 |
+
type="service_unavailable",
|
| 171 |
+
code="503",
|
| 172 |
+
)
|
| 173 |
+
return JSONResponse(
|
| 174 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 175 |
+
content=ErrorResponse(error=error_detail).model_dump(),
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# 2b. Handle Hybrid Official API Routing
|
| 179 |
+
if selected_provider.startswith("api-"):
|
| 180 |
+
import httpx
|
| 181 |
+
|
| 182 |
+
# Resolve target API URL and Authorization headers
|
| 183 |
+
api_url = ""
|
| 184 |
+
headers = {"Content-Type": "application/json"}
|
| 185 |
+
|
| 186 |
+
if selected_provider == "api-mistral":
|
| 187 |
+
api_url = "https://api.mistral.ai/v1/chat/completions"
|
| 188 |
+
headers["Authorization"] = f"Bearer {settings.mistral_api_key}"
|
| 189 |
+
elif selected_provider == "api-nvidia-nim":
|
| 190 |
+
api_url = "https://integrate.api.nvidia.com/v1/chat/completions"
|
| 191 |
+
headers["Authorization"] = f"Bearer {settings.nvidia_nim_api_key}"
|
| 192 |
+
elif selected_provider == "api-deepseek":
|
| 193 |
+
api_url = "https://api.deepseek.com/v1/chat/completions"
|
| 194 |
+
headers["Authorization"] = f"Bearer {settings.deepseek_api_key}"
|
| 195 |
+
elif selected_provider == "api-anthropic":
|
| 196 |
+
api_url = "https://api.anthropic.com/v1/messages"
|
| 197 |
+
headers["x-api-key"] = settings.anthropic_api_key
|
| 198 |
+
headers["anthropic-version"] = "2023-06-01"
|
| 199 |
+
elif selected_provider == "api-z-ai":
|
| 200 |
+
api_url = "https://api.z.ai/v1/chat/completions"
|
| 201 |
+
headers["Authorization"] = f"Bearer {settings.z_ai_api_key}"
|
| 202 |
+
|
| 203 |
+
if not api_url:
|
| 204 |
+
raise HTTPException(status_code=500, detail="API URL not configured for selected hybrid provider.")
|
| 205 |
+
|
| 206 |
+
# Prepare request payload matching standard OpenAI schemas
|
| 207 |
+
# Note: Anthropic uses a different schema, but we keep it simple for OpenAI compatible endpoints here
|
| 208 |
+
payload = request.model_dump(exclude_none=True)
|
| 209 |
+
# Override the model name in request to use the canonical official API model
|
| 210 |
+
if selected_provider == "api-mistral":
|
| 211 |
+
payload["model"] = "mistral-large-latest"
|
| 212 |
+
elif selected_provider == "api-nvidia-nim":
|
| 213 |
+
payload["model"] = "meta/llama3-70b-instruct"
|
| 214 |
+
elif selected_provider == "api-deepseek":
|
| 215 |
+
payload["model"] = "deepseek-chat"
|
| 216 |
+
elif selected_provider == "api-anthropic":
|
| 217 |
+
# Direct mapping from openai to anthropic messages format if needed,
|
| 218 |
+
# but for hybrid fallbacks we assume standard OpenAI endpoints or proxy models.
|
| 219 |
+
payload["model"] = "claude-3-5-sonnet-latest"
|
| 220 |
+
elif selected_provider == "api-z-ai":
|
| 221 |
+
payload["model"] = "z-ai-latest"
|
| 222 |
+
|
| 223 |
+
if request.stream:
|
| 224 |
+
async def official_stream_generator() -> AsyncGenerator[dict, None]:
|
| 225 |
+
async with httpx.AsyncClient() as client:
|
| 226 |
+
try:
|
| 227 |
+
async with client.stream("POST", api_url, headers=headers, json=payload, timeout=60.0) as resp:
|
| 228 |
+
if resp.status_code != 200:
|
| 229 |
+
yield {"data": f"[ERROR] Official API returned status code {resp.status_code}"}
|
| 230 |
+
yield {"data": "[DONE]"}
|
| 231 |
+
return
|
| 232 |
+
async for line in resp.aiter_lines():
|
| 233 |
+
if line.strip():
|
| 234 |
+
yield {"data": line}
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error("Error in hybrid official API streaming: %s", e)
|
| 237 |
+
yield {"data": f"[ERROR] {str(e)}"}
|
| 238 |
+
yield {"data": "[DONE]"}
|
| 239 |
+
return EventSourceResponse(official_stream_generator())
|
| 240 |
+
else:
|
| 241 |
+
async with httpx.AsyncClient() as client:
|
| 242 |
+
try:
|
| 243 |
+
resp = await client.post(api_url, headers=headers, json=payload, timeout=60.0)
|
| 244 |
+
if resp.status_code != 200:
|
| 245 |
+
raise HTTPException(status_code=resp.status_code, detail=f"Official API Error: {resp.text}")
|
| 246 |
+
return JSONResponse(status_code=200, content=resp.json())
|
| 247 |
+
except Exception as e:
|
| 248 |
+
logger.error("Error in hybrid official API: %s", e)
|
| 249 |
+
raise HTTPException(status_code=500, detail=f"Hybrid API call failed: {str(e)}")
|
| 250 |
+
|
| 251 |
+
# 3. Create the internal Task
|
| 252 |
+
session_id = None
|
| 253 |
+
conversation_url = None
|
| 254 |
+
action = "continue"
|
| 255 |
+
|
| 256 |
+
if request.user:
|
| 257 |
+
user_str = request.user.strip()
|
| 258 |
+
if user_str.startswith("session:") or user_str.startswith("resume:"):
|
| 259 |
+
parts = user_str.split(":", 1)
|
| 260 |
+
target_sid = parts[1]
|
| 261 |
+
from core.sessions import session_store
|
| 262 |
+
session = await session_store.get_session(target_sid)
|
| 263 |
+
if session:
|
| 264 |
+
session_id = session.session_id
|
| 265 |
+
conversation_url = session.conversation_url
|
| 266 |
+
action = "resume_chat" if conversation_url else "new_chat"
|
| 267 |
+
logger.info("Resuming session: %s (url: %s)", session_id, conversation_url)
|
| 268 |
+
elif user_str.startswith("new_chat"):
|
| 269 |
+
from core.sessions import session_store
|
| 270 |
+
parts = user_str.split(":", 1)
|
| 271 |
+
prov = parts[1] if len(parts) > 1 else selected_provider
|
| 272 |
+
session = await session_store.create_session(provider=prov)
|
| 273 |
+
session_id = session.session_id
|
| 274 |
+
action = "new_chat"
|
| 275 |
+
logger.info("Created new session: %s for provider: %s", session_id, prov)
|
| 276 |
+
else:
|
| 277 |
+
# Maybe it is a raw session_id
|
| 278 |
+
from core.sessions import session_store
|
| 279 |
+
session = await session_store.get_session(user_str)
|
| 280 |
+
if session:
|
| 281 |
+
session_id = session.session_id
|
| 282 |
+
conversation_url = session.conversation_url
|
| 283 |
+
action = "resume_chat" if conversation_url else "new_chat"
|
| 284 |
+
logger.info("Resuming session via raw ID: %s", session_id)
|
| 285 |
+
|
| 286 |
+
task = Task(
|
| 287 |
+
provider=selected_provider,
|
| 288 |
+
model=requested_model,
|
| 289 |
+
messages=messages_dump,
|
| 290 |
+
temperature=request.temperature,
|
| 291 |
+
max_tokens=request.max_tokens,
|
| 292 |
+
stream=request.stream,
|
| 293 |
+
session_id=session_id,
|
| 294 |
+
conversation_url=conversation_url,
|
| 295 |
+
action=action,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# 4. Submit to queue
|
| 299 |
+
try:
|
| 300 |
+
handle = await task_queue.submit_task(task)
|
| 301 |
+
except asyncio.QueueFull:
|
| 302 |
+
error_detail = ErrorDetail(
|
| 303 |
+
message="Nancy task queue is currently full. Try again later.",
|
| 304 |
+
type="rate_limit_error",
|
| 305 |
+
code="429",
|
| 306 |
+
)
|
| 307 |
+
return JSONResponse(
|
| 308 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 309 |
+
content=ErrorResponse(error=error_detail).model_dump(),
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
# 5. Handle Streaming Response (stream=True)
|
| 313 |
+
if request.stream:
|
| 314 |
+
async def stream_generator() -> AsyncGenerator[dict, None]:
|
| 315 |
+
completion_id = handle.task.completion_id
|
| 316 |
+
try:
|
| 317 |
+
if request.tools:
|
| 318 |
+
buffer = []
|
| 319 |
+
is_potential_json = False
|
| 320 |
+
streamed_buffer = False
|
| 321 |
+
|
| 322 |
+
# Stream response chunks from the queue
|
| 323 |
+
async for chunk in task_queue.stream_chunks(handle):
|
| 324 |
+
if not buffer:
|
| 325 |
+
stripped = chunk.strip()
|
| 326 |
+
if stripped.startswith("{") or stripped.startswith("`"):
|
| 327 |
+
is_potential_json = True
|
| 328 |
+
|
| 329 |
+
if is_potential_json and not streamed_buffer:
|
| 330 |
+
buffer.append(chunk)
|
| 331 |
+
if sum(len(c) for c in buffer) > 1536:
|
| 332 |
+
yield {
|
| 333 |
+
"data": ChatCompletionChunk.first_chunk(
|
| 334 |
+
completion_id, requested_model
|
| 335 |
+
).to_sse_data()
|
| 336 |
+
}
|
| 337 |
+
for b_chunk in buffer:
|
| 338 |
+
yield {
|
| 339 |
+
"data": ChatCompletionChunk.content_chunk(
|
| 340 |
+
completion_id, requested_model, b_chunk
|
| 341 |
+
).to_sse_data()
|
| 342 |
+
}
|
| 343 |
+
streamed_buffer = True
|
| 344 |
+
else:
|
| 345 |
+
if not streamed_buffer and not is_potential_json:
|
| 346 |
+
yield {
|
| 347 |
+
"data": ChatCompletionChunk.first_chunk(
|
| 348 |
+
completion_id, requested_model
|
| 349 |
+
).to_sse_data()
|
| 350 |
+
}
|
| 351 |
+
is_potential_json = True
|
| 352 |
+
yield {
|
| 353 |
+
"data": ChatCompletionChunk.content_chunk(
|
| 354 |
+
completion_id, requested_model, chunk
|
| 355 |
+
).to_sse_data()
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
# Flush or parse buffer
|
| 359 |
+
if is_potential_json and not streamed_buffer:
|
| 360 |
+
full_text = "".join(buffer)
|
| 361 |
+
tool_calls = parse_tool_call_json(full_text)
|
| 362 |
+
if tool_calls:
|
| 363 |
+
from models.openai import StreamChoice, DeltaContent
|
| 364 |
+
yield {
|
| 365 |
+
"data": ChatCompletionChunk(
|
| 366 |
+
id=completion_id,
|
| 367 |
+
model=requested_model,
|
| 368 |
+
choices=[
|
| 369 |
+
StreamChoice(
|
| 370 |
+
index=0,
|
| 371 |
+
delta=DeltaContent(
|
| 372 |
+
role="assistant",
|
| 373 |
+
tool_calls=tool_calls
|
| 374 |
+
),
|
| 375 |
+
finish_reason="tool_calls"
|
| 376 |
+
)
|
| 377 |
+
]
|
| 378 |
+
).to_sse_data()
|
| 379 |
+
}
|
| 380 |
+
else:
|
| 381 |
+
yield {
|
| 382 |
+
"data": ChatCompletionChunk.first_chunk(
|
| 383 |
+
completion_id, requested_model
|
| 384 |
+
).to_sse_data()
|
| 385 |
+
}
|
| 386 |
+
for b_chunk in buffer:
|
| 387 |
+
yield {
|
| 388 |
+
"data": ChatCompletionChunk.content_chunk(
|
| 389 |
+
completion_id, requested_model, b_chunk
|
| 390 |
+
).to_sse_data()
|
| 391 |
+
}
|
| 392 |
+
else:
|
| 393 |
+
yield {
|
| 394 |
+
"data": ChatCompletionChunk.first_chunk(
|
| 395 |
+
completion_id, requested_model
|
| 396 |
+
).to_sse_data()
|
| 397 |
+
}
|
| 398 |
+
async for chunk in task_queue.stream_chunks(handle):
|
| 399 |
+
yield {
|
| 400 |
+
"data": ChatCompletionChunk.content_chunk(
|
| 401 |
+
completion_id, requested_model, chunk
|
| 402 |
+
).to_sse_data()
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
# Final chunk: finish reason
|
| 406 |
+
yield {
|
| 407 |
+
"data": ChatCompletionChunk.final_chunk(
|
| 408 |
+
completion_id, requested_model, "stop"
|
| 409 |
+
).to_sse_data()
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
# Raw [DONE] terminator
|
| 413 |
+
yield {"data": "[DONE]"}
|
| 414 |
+
|
| 415 |
+
except Exception as exc:
|
| 416 |
+
logger.error("Error streaming chunks for task %s: %s", handle.task_id, exc)
|
| 417 |
+
error_chunk = ChatCompletionChunk.final_chunk(
|
| 418 |
+
completion_id, requested_model, "length"
|
| 419 |
+
)
|
| 420 |
+
yield {"data": error_chunk.to_sse_data()}
|
| 421 |
+
yield {"data": "[DONE]"}
|
| 422 |
+
finally:
|
| 423 |
+
# Release resources
|
| 424 |
+
task_queue.cleanup_task(handle.task_id)
|
| 425 |
+
|
| 426 |
+
return EventSourceResponse(stream_generator())
|
| 427 |
+
|
| 428 |
+
# 6. Handle Non-streaming Blocking Response (stream=False)
|
| 429 |
+
else:
|
| 430 |
+
try:
|
| 431 |
+
# Drain the queue to aggregate response chunks
|
| 432 |
+
chunks = []
|
| 433 |
+
async for chunk in task_queue.stream_chunks(handle):
|
| 434 |
+
chunks.append(chunk)
|
| 435 |
+
|
| 436 |
+
# Check if task failed or timed out
|
| 437 |
+
if handle.task.status == TaskStatus.FAILED:
|
| 438 |
+
raise HTTPException(
|
| 439 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 440 |
+
detail=f"Chatbot provider failed: {handle.task.error}",
|
| 441 |
+
)
|
| 442 |
+
elif handle.task.status == TaskStatus.TIMED_OUT:
|
| 443 |
+
raise HTTPException(
|
| 444 |
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
| 445 |
+
detail="Chatbot provider timed out responding.",
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
full_content = "".join(chunks)
|
| 449 |
+
if request.tools:
|
| 450 |
+
tool_calls = parse_tool_call_json(full_content)
|
| 451 |
+
if tool_calls:
|
| 452 |
+
from models.openai import Choice, ChoiceMessage
|
| 453 |
+
response = ChatCompletionResponse(
|
| 454 |
+
id=handle.task.completion_id,
|
| 455 |
+
model=requested_model,
|
| 456 |
+
choices=[
|
| 457 |
+
Choice(
|
| 458 |
+
index=0,
|
| 459 |
+
message=ChoiceMessage(
|
| 460 |
+
role="assistant",
|
| 461 |
+
content=None,
|
| 462 |
+
tool_calls=tool_calls
|
| 463 |
+
),
|
| 464 |
+
finish_reason="tool_calls"
|
| 465 |
+
)
|
| 466 |
+
]
|
| 467 |
+
)
|
| 468 |
+
return response
|
| 469 |
+
|
| 470 |
+
response = ChatCompletionResponse.from_content(
|
| 471 |
+
content=full_content,
|
| 472 |
+
model=requested_model,
|
| 473 |
+
)
|
| 474 |
+
response.id = handle.task.completion_id
|
| 475 |
+
return response
|
| 476 |
+
|
| 477 |
+
except HTTPException:
|
| 478 |
+
raise
|
| 479 |
+
except Exception as exc:
|
| 480 |
+
logger.error("Error completing non-streaming task %s: %s", handle.task_id, exc)
|
| 481 |
+
raise HTTPException(
|
| 482 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 483 |
+
detail=f"Nancy internal server error: {exc}",
|
| 484 |
+
)
|
| 485 |
+
finally:
|
| 486 |
+
# Release resources
|
| 487 |
+
task_queue.cleanup_task(handle.task_id)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
@router.get("/models", response_model=ModelListResponse)
|
| 491 |
+
async def list_models(api_key: str = Depends(require_api_key)):
|
| 492 |
+
"""
|
| 493 |
+
List Available OpenAI Models.
|
| 494 |
+
Maps to available providers configured in Nancy.
|
| 495 |
+
"""
|
| 496 |
+
models = provider_router.get_available_models()
|
| 497 |
+
model_infos = [ModelInfo(id=model) for model in models]
|
| 498 |
+
return ModelListResponse(data=model_infos)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
@router.get("/models/{model}", response_model=ModelInfo)
|
| 502 |
+
async def get_model(model: str, api_key: str = Depends(require_api_key)):
|
| 503 |
+
"""
|
| 504 |
+
Retrieve specific model details.
|
| 505 |
+
"""
|
| 506 |
+
models = provider_router.get_available_models()
|
| 507 |
+
if model.lower() not in models:
|
| 508 |
+
raise HTTPException(
|
| 509 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 510 |
+
detail=f"Model '{model}' not found in Nancy configuration.",
|
| 511 |
+
)
|
| 512 |
+
return ModelInfo(id=model.lower())
|
routers/extension.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Extension Relay Router.
|
| 3 |
+
|
| 4 |
+
Exposes endpoints for the Chrome extension:
|
| 5 |
+
- GET /ext/tasks/stream (SSE) β Extension receives new task assignments
|
| 6 |
+
- POST /ext/heartbeat β Extension reports health and active tasks
|
| 7 |
+
- POST /ext/response β Extension streams chunks and signals completion / error
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
from typing import AsyncGenerator
|
| 17 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 18 |
+
from sse_starlette.sse import EventSourceResponse
|
| 19 |
+
|
| 20 |
+
from config import settings
|
| 21 |
+
from core.auth import require_ext_secret
|
| 22 |
+
from core.queue import task_queue
|
| 23 |
+
from core.router import provider_router
|
| 24 |
+
from core.sessions import session_store
|
| 25 |
+
from models.task import ExtensionHeartbeat, ExtensionResponseChunk
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger("nancy.extension")
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/ext", tags=["Extension Relay"])
|
| 30 |
+
|
| 31 |
+
# Global dictionary to track connected extension instances: extension_id -> last_seen_timestamp
|
| 32 |
+
active_extensions: dict[str, float] = {}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/tasks/stream")
|
| 36 |
+
async def tasks_stream(
|
| 37 |
+
request: Request,
|
| 38 |
+
secret: str = Depends(require_ext_secret),
|
| 39 |
+
):
|
| 40 |
+
"""
|
| 41 |
+
Server-Sent Events (SSE) stream for Chrome Extensions.
|
| 42 |
+
Delivers task assignments to connected extensions.
|
| 43 |
+
"""
|
| 44 |
+
extension_id = request.query_params.get("extension_id", "default")
|
| 45 |
+
active_extensions[extension_id] = time.time()
|
| 46 |
+
logger.info("Extension client '%s' connected to task stream", extension_id)
|
| 47 |
+
|
| 48 |
+
async def event_generator() -> AsyncGenerator[dict, None]:
|
| 49 |
+
try:
|
| 50 |
+
while True:
|
| 51 |
+
# Disconnect check
|
| 52 |
+
if await request.is_disconnected():
|
| 53 |
+
logger.info("Extension client '%s' disconnected from stream", extension_id)
|
| 54 |
+
break
|
| 55 |
+
|
| 56 |
+
# Update heartbeat timestamp
|
| 57 |
+
active_extensions[extension_id] = time.time()
|
| 58 |
+
|
| 59 |
+
# Dequeue a task. We use a 15-second timeout so we can yield a ping if idle.
|
| 60 |
+
task = await task_queue.dequeue_task(timeout=15.0)
|
| 61 |
+
if task:
|
| 62 |
+
logger.info("Relaying task %s to extension '%s'", task.task_id, extension_id)
|
| 63 |
+
yield {
|
| 64 |
+
"event": "task",
|
| 65 |
+
"data": json.dumps(task.to_extension_payload()),
|
| 66 |
+
"id": task.task_id,
|
| 67 |
+
}
|
| 68 |
+
else:
|
| 69 |
+
# Keep-alive SSE ping
|
| 70 |
+
yield {
|
| 71 |
+
"event": "ping",
|
| 72 |
+
"data": "keep-alive",
|
| 73 |
+
}
|
| 74 |
+
except asyncio.CancelledError:
|
| 75 |
+
logger.info("Extension client '%s' stream cancelled", extension_id)
|
| 76 |
+
finally:
|
| 77 |
+
active_extensions.pop(extension_id, None)
|
| 78 |
+
|
| 79 |
+
return EventSourceResponse(event_generator())
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.post("/heartbeat")
|
| 83 |
+
async def heartbeat(
|
| 84 |
+
payload: ExtensionHeartbeat,
|
| 85 |
+
secret: str = Depends(require_ext_secret),
|
| 86 |
+
):
|
| 87 |
+
"""
|
| 88 |
+
Extension health check. Pinned every 25s by active extension instances.
|
| 89 |
+
"""
|
| 90 |
+
active_extensions[payload.extension_id] = time.time()
|
| 91 |
+
logger.debug(
|
| 92 |
+
"Extension '%s' heartbeat received (active_tasks=%d)",
|
| 93 |
+
payload.extension_id,
|
| 94 |
+
len(payload.active_tasks),
|
| 95 |
+
)
|
| 96 |
+
return {"status": "ok", "timestamp": time.time()}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.post("/response")
|
| 100 |
+
async def receive_response(
|
| 101 |
+
payload: ExtensionResponseChunk,
|
| 102 |
+
secret: str = Depends(require_ext_secret),
|
| 103 |
+
):
|
| 104 |
+
"""
|
| 105 |
+
Receive streaming response chunks and completion/error notifications from Chrome Extension.
|
| 106 |
+
"""
|
| 107 |
+
task_id = payload.task_id
|
| 108 |
+
handle = task_queue.get_handle(task_id)
|
| 109 |
+
|
| 110 |
+
if not handle:
|
| 111 |
+
logger.warning("Received chunk/completion for unknown task %s", task_id)
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 114 |
+
detail=f"Task {task_id} not found or expired.",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# 1. Handle error reported by the extension
|
| 118 |
+
if payload.error:
|
| 119 |
+
logger.error("Extension reported failure for task %s: %s", task_id, payload.error)
|
| 120 |
+
task_queue.complete_task(task_id, error=payload.error)
|
| 121 |
+
provider_router.record_failure(handle.task.provider)
|
| 122 |
+
return {"status": "error_registered"}
|
| 123 |
+
|
| 124 |
+
# 2. Push text chunk if present
|
| 125 |
+
if payload.chunk:
|
| 126 |
+
task_queue.push_chunk(task_id, payload.chunk)
|
| 127 |
+
|
| 128 |
+
# 3. Handle final chunk/completion signal
|
| 129 |
+
if payload.is_done:
|
| 130 |
+
logger.info("Extension completed task %s successfully", task_id)
|
| 131 |
+
task_queue.complete_task(task_id, error=None)
|
| 132 |
+
provider_router.record_success(handle.task.provider)
|
| 133 |
+
|
| 134 |
+
# 4. Auto-update session URL if extension reported back the conversation URL
|
| 135 |
+
if payload.conversation_url and handle.task.session_id:
|
| 136 |
+
try:
|
| 137 |
+
await session_store.update_session_url(
|
| 138 |
+
session_id=handle.task.session_id,
|
| 139 |
+
conversation_url=payload.conversation_url,
|
| 140 |
+
message_count_delta=1,
|
| 141 |
+
)
|
| 142 |
+
logger.info(
|
| 143 |
+
"Session '%s' URL updated to '%s'",
|
| 144 |
+
handle.task.session_id[:8],
|
| 145 |
+
payload.conversation_url,
|
| 146 |
+
)
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.warning("Failed to update session URL: %s", e)
|
| 149 |
+
|
| 150 |
+
return {"status": "accepted"}
|
routers/health.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Health & Status Router.
|
| 3 |
+
|
| 4 |
+
Provides health checks and detailed status monitoring endpoints for system observability.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
from fastapi import APIRouter, Depends
|
| 11 |
+
|
| 12 |
+
from core.auth import require_api_key
|
| 13 |
+
from core.queue import task_queue
|
| 14 |
+
from core.router import provider_router
|
| 15 |
+
|
| 16 |
+
router = APIRouter(tags=["Health & Monitoring"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/health")
|
| 20 |
+
async def health_check():
|
| 21 |
+
"""
|
| 22 |
+
Simple Liveness/Readiness Probe.
|
| 23 |
+
Used by keep-alive cron pings to prevent HF Space sleeping.
|
| 24 |
+
"""
|
| 25 |
+
return {
|
| 26 |
+
"status": "healthy",
|
| 27 |
+
"timestamp": time.time(),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/status")
|
| 32 |
+
async def detailed_status(api_key: str = Depends(require_api_key)):
|
| 33 |
+
"""
|
| 34 |
+
Detailed System Status.
|
| 35 |
+
Requires Nancy API key authentication. Returns task queue size,
|
| 36 |
+
provider circuit breaker states, and connected extension sessions.
|
| 37 |
+
"""
|
| 38 |
+
# Import active_extensions dynamically to avoid circular import
|
| 39 |
+
active_exts = {}
|
| 40 |
+
try:
|
| 41 |
+
from routers.extension import active_extensions
|
| 42 |
+
now = time.time()
|
| 43 |
+
for ext_id, last_seen in list(active_extensions.items()):
|
| 44 |
+
active_exts[ext_id] = {
|
| 45 |
+
"last_seen_ago": round(now - last_seen, 1),
|
| 46 |
+
"online": (now - last_seen) < 30.0, # 30 seconds threshold
|
| 47 |
+
}
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
"status": "running",
|
| 53 |
+
"timestamp": time.time(),
|
| 54 |
+
"queue": task_queue.get_status(),
|
| 55 |
+
"router": {
|
| 56 |
+
"providers": provider_router.get_provider_states(),
|
| 57 |
+
"available_models": provider_router.get_available_models(),
|
| 58 |
+
},
|
| 59 |
+
"active_tasks": task_queue.get_active_tasks(),
|
| 60 |
+
"recent_history": task_queue.get_history(20),
|
| 61 |
+
"connected_extensions": active_exts,
|
| 62 |
+
}
|
routers/sessions.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nancy HF Space β Session REST API Router.
|
| 3 |
+
|
| 4 |
+
Provides session management endpoints so agents can:
|
| 5 |
+
- Create new tracked conversation sessions
|
| 6 |
+
- List all sessions (optionally filtered by provider)
|
| 7 |
+
- Get a specific session's state
|
| 8 |
+
- Delete / archive sessions
|
| 9 |
+
- Update a session's conversation URL (called internally after task completion)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Any
|
| 16 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 17 |
+
from fastapi.responses import JSONResponse
|
| 18 |
+
from pydantic import BaseModel, Field
|
| 19 |
+
|
| 20 |
+
from core.auth import require_api_key
|
| 21 |
+
from core.sessions import session_store
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger("nancy.sessions_router")
|
| 24 |
+
|
| 25 |
+
router = APIRouter(prefix="/v1/sessions", tags=["Session Management"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ββ Request / Response Schemas ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
class CreateSessionRequest(BaseModel):
|
| 31 |
+
"""Request body for creating a new conversation session."""
|
| 32 |
+
provider: str = Field(
|
| 33 |
+
...,
|
| 34 |
+
description="Target provider key (e.g. 'chatgpt', 'gemini', 'nim'). "
|
| 35 |
+
"Determines which browser tab / official API to use."
|
| 36 |
+
)
|
| 37 |
+
title: str | None = Field(
|
| 38 |
+
default=None,
|
| 39 |
+
description="Optional human-readable title for this session."
|
| 40 |
+
)
|
| 41 |
+
system_prompt: str | None = Field(
|
| 42 |
+
default=None,
|
| 43 |
+
description="Optional system prompt to prepend when starting a new chat."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class UpdateSessionURLRequest(BaseModel):
|
| 48 |
+
"""Internal: update a session's conversation URL after a chat."""
|
| 49 |
+
conversation_url: str = Field(
|
| 50 |
+
...,
|
| 51 |
+
description="The browser tab URL for the active conversation (e.g. chatgpt.com/c/abc123)."
|
| 52 |
+
)
|
| 53 |
+
message_count_delta: int = Field(
|
| 54 |
+
default=1,
|
| 55 |
+
description="How many messages to add to the running total."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
|
| 61 |
+
@router.post("", status_code=status.HTTP_201_CREATED)
|
| 62 |
+
async def create_session(
|
| 63 |
+
body: CreateSessionRequest,
|
| 64 |
+
api_key: str = Depends(require_api_key),
|
| 65 |
+
) -> dict[str, Any]:
|
| 66 |
+
"""
|
| 67 |
+
Create a new conversation session.
|
| 68 |
+
|
| 69 |
+
Returns the session_id and metadata. Use this session_id in subsequent
|
| 70 |
+
chat completion requests (as the `user` field or via custom header) to
|
| 71 |
+
automatically resume the same conversation tab.
|
| 72 |
+
"""
|
| 73 |
+
session = await session_store.create_session(
|
| 74 |
+
provider=body.provider,
|
| 75 |
+
title=body.title,
|
| 76 |
+
system_prompt=body.system_prompt,
|
| 77 |
+
)
|
| 78 |
+
logger.info("Created session '%s' for provider '%s'", session.session_id[:8], body.provider)
|
| 79 |
+
return {
|
| 80 |
+
"session_id": session.session_id,
|
| 81 |
+
"message": f"Session created for provider '{body.provider}'.",
|
| 82 |
+
"session": session.to_dict(),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("")
|
| 87 |
+
async def list_sessions(
|
| 88 |
+
provider: str | None = Query(default=None, description="Filter by provider key."),
|
| 89 |
+
api_key: str = Depends(require_api_key),
|
| 90 |
+
) -> dict[str, Any]:
|
| 91 |
+
"""
|
| 92 |
+
List all tracked sessions, optionally filtered by provider.
|
| 93 |
+
Sessions are sorted by last_used_at (most recent first).
|
| 94 |
+
"""
|
| 95 |
+
sessions = await session_store.list_sessions(provider=provider)
|
| 96 |
+
return {
|
| 97 |
+
"total": len(sessions),
|
| 98 |
+
"sessions": [s.to_dict() for s in sessions],
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.get("/{session_id}")
|
| 103 |
+
async def get_session(
|
| 104 |
+
session_id: str,
|
| 105 |
+
api_key: str = Depends(require_api_key),
|
| 106 |
+
) -> dict[str, Any]:
|
| 107 |
+
"""
|
| 108 |
+
Retrieve a specific session by its ID.
|
| 109 |
+
"""
|
| 110 |
+
session = await session_store.get_session(session_id)
|
| 111 |
+
if not session:
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 114 |
+
detail=f"Session '{session_id}' not found.",
|
| 115 |
+
)
|
| 116 |
+
return session.to_dict()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@router.patch("/{session_id}/url")
|
| 120 |
+
async def update_session_url(
|
| 121 |
+
session_id: str,
|
| 122 |
+
body: UpdateSessionURLRequest,
|
| 123 |
+
api_key: str = Depends(require_api_key),
|
| 124 |
+
) -> dict[str, Any]:
|
| 125 |
+
"""
|
| 126 |
+
Update a session's conversation URL.
|
| 127 |
+
|
| 128 |
+
Called after a task completes to record the browser URL for the conversation,
|
| 129 |
+
enabling future resume operations.
|
| 130 |
+
"""
|
| 131 |
+
session = await session_store.get_session(session_id)
|
| 132 |
+
if not session:
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 135 |
+
detail=f"Session '{session_id}' not found.",
|
| 136 |
+
)
|
| 137 |
+
await session_store.update_session_url(
|
| 138 |
+
session_id=session_id,
|
| 139 |
+
conversation_url=body.conversation_url,
|
| 140 |
+
message_count_delta=body.message_count_delta,
|
| 141 |
+
)
|
| 142 |
+
updated = await session_store.get_session(session_id)
|
| 143 |
+
return {
|
| 144 |
+
"message": "Session URL updated.",
|
| 145 |
+
"session": updated.to_dict() if updated else {},
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@router.delete("/{session_id}", status_code=status.HTTP_200_OK)
|
| 150 |
+
async def delete_session(
|
| 151 |
+
session_id: str,
|
| 152 |
+
api_key: str = Depends(require_api_key),
|
| 153 |
+
) -> dict[str, Any]:
|
| 154 |
+
"""
|
| 155 |
+
Archive / soft-delete a session.
|
| 156 |
+
The session record is kept but marked as archived and excluded from list results.
|
| 157 |
+
"""
|
| 158 |
+
deleted = await session_store.delete_session(session_id)
|
| 159 |
+
if not deleted:
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 162 |
+
detail=f"Session '{session_id}' not found.",
|
| 163 |
+
)
|
| 164 |
+
return {"message": f"Session '{session_id[:8]}...' archived successfully."}
|