nexusagent-redis commited on
Commit
1ebb69b
Β·
0 Parent(s):

Deploy Nancy Relay Gateway

Browse files
.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
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."}