Wimmboo commited on
Commit
a056d23
·
0 Parent(s):

feat: add OpenAI-compatible API proxy with NVIDIA and Google integration

Browse files

- Created .env.example for environment variable configuration
- Added .gitignore to exclude sensitive files and directories
- Implemented OpenCode plugin for knowledge graph reminders
- Documented agent behavior and product details in AGENTS.md and PRODUCT.md
- Defined design principles and layout in DESIGN.md
- Configured application settings in config.py
- Developed main application entry point in main.py
- Established proxy authentication and request handling in auth.py and client.py
- Registered providers and their configurations in providers.py
- Set up FastAPI router with endpoints for chat completions and health checks in router.py
- Included requirements.txt for dependency management
- Built static HTML dashboard for user interface

.env.example ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Real provider API keys (keep these secret)
2
+ REAL_NVIDIA_KEY=nvapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
3
+ REAL_GOOGLE_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
4
+
5
+ # Proxy keys your client apps will use
6
+ # Generate with: python -c "import secrets; print('sk-nvidia-' + secrets.token_urlsafe(32))"
7
+ SK_NVIDIA_KEY=sk-nvidia-xxxxxxxxxxxxxxxxxxxx
8
+ SK_GOOGLE_KEY=sk-google-xxxxxxxxxxxxxxxxxxxx
9
+
10
+ # Server
11
+ HOST=127.0.0.1
12
+ PORT=8000
13
+
14
+ # NVIDIA async polling (when a 202 response is returned)
15
+ POLL_INTERVAL_SECONDS=2
16
+ POLL_MAX_ATTEMPTS=60
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Secrets
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ .eggs/
13
+
14
+ # Virtual env
15
+ .venv/
16
+ venv/
17
+
18
+ # Graphify
19
+ graphify-out/
20
+ .graphify_*.json
21
+
22
+ # IDE
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ *.swo
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
.opencode/opencode.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "plugin": [
3
+ ".opencode/plugins/graphify.js"
4
+ ]
5
+ }
.opencode/plugins/graphify.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // graphify OpenCode plugin
2
+ // Injects a knowledge graph reminder before bash tool calls when the graph exists.
3
+ //
4
+ // IMPORTANT: keep the reminder string free of backticks and $(...) constructs.
5
+ // The hook prepends `echo "<reminder>" && <cmd>` to the user's bash command;
6
+ // backticks inside the double-quoted echo trigger bash command substitution,
7
+ // which both corrupts tool output and silently executes the very graphify
8
+ // command we are only suggesting. Plain words render fine in opencode's TUI.
9
+ import { existsSync } from "fs";
10
+ import { join } from "path";
11
+
12
+ export const GraphifyPlugin = async ({ directory }) => {
13
+ let reminded = false;
14
+
15
+ return {
16
+ "tool.execute.before": async (input, output) => {
17
+ if (reminded) return;
18
+ if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
19
+
20
+ if (input.tool === "bash") {
21
+ output.args.command =
22
+ 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." && ' +
23
+ output.args.command;
24
+ reminded = true;
25
+ }
26
+ },
27
+ };
28
+ };
AGENTS.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## graphify
2
+
3
+ This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
4
+
5
+ When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
6
+
7
+ Rules:
8
+ - For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
9
+ - Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
10
+ - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
11
+ - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
12
+ - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
13
+
14
+ ## OpenAI API Proxy
15
+
16
+ FastAPI proxy that routes OpenAI-compatible chat requests to NVIDIA NIM or Google AI Studio based on which `sk-{provider}` key the client uses.
17
+
18
+ ### Setup & run
19
+ - Install: `pip install -r requirements.txt`
20
+ - Copy `.env.example` to `.env` and fill in real provider API keys + proxy keys
21
+ - Run: `python main.py` (uvicorn, http://127.0.0.1:8000)
22
+ - Client calls `POST /v1/chat/completions` with `Authorization: Bearer sk-{provider}-...`
23
+
24
+ ### Architecture
25
+ - `config.py` — loads `.env`, exposes `PROVIDER_KEYS` (proxy→real key mapping) and `REAL_KEYS`
26
+ - `proxy/providers.py` — `Provider` dataclass registry; adds `env_real_key` field name, `auth_header`, `handles_202` flag
27
+ - `proxy/auth.py` — `resolve_provider()` matches Bearer token against `PROVIDER_KEYS`; `get_real_key()` reads the real API key from env
28
+ - `proxy/client.py` — `forward_request()` streams or polls upstream; NVIDIA returns HTTP 202 then polls `/v1/status/{id}`
29
+ - `proxy/router.py` — FastAPI router with `/v1/models`, `/v1/chat/completions`, `/v1/health`, `/v1/keys`; `create_app()` factory mounts static files
30
+
31
+ ### Provider quirks
32
+ - **NVIDIA**: Bearer token auth, async (202→poll), base URL `https://integrate.api.nvidia.com`
33
+ - **Google**: `x-goog-api-key` header (no Bearer), no 202 handling, base URL `https://generativelanguage.googleapis.com/v1beta/openai`
34
+
35
+ ### Adding a new provider
36
+ 1. Add `Provider(...)` to `proxy/providers.py:PROVIDERS` dict
37
+ 2. Add `REAL_*_KEY` and `SK_*_KEY` env vars in `config.py` and `.env.example`
38
+ 3. Add model list to `MODELS` dict in `proxy/router.py`
39
+
40
+ ### Constraints
41
+ - No tests, no CI, no linter/formatter config — not yet set up
42
+ - No database — pure HTTP proxy
43
+ - Timeouts: connect=10s, read=180s; NVIDIA poll interval=2s, max 60 attempts
DESIGN.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design
2
+
3
+ ## Theme
4
+
5
+ Dark-mode personal devtool dashboard. Mood: "precise switchboard at night."
6
+
7
+ ## Color strategy
8
+
9
+ Restrained: near-black surface, off-white text, one warm primary, one cool functional accent.
10
+
11
+ ```css
12
+ :root {
13
+ --bg: oklch(0.08 0 0); /* deep void, not pure black */
14
+ --surface: oklch(0.12 0 0); /* cards, panels */
15
+ --surface-2: oklch(0.16 0 0); /* hovered/elevated cards */
16
+ --ink: oklch(0.94 0 0); /* primary text */
17
+ --muted: oklch(0.62 0 0); /* secondary text */
18
+ --primary: oklch(0.68 0.13 38); /* warm coral — links, active states */
19
+ --primary-text: oklch(1 0 0); /* white text on primary fills */
20
+ --accent: oklch(0.72 0.12 175); /* teal — status, success, online */
21
+ --warning: oklch(0.76 0.12 85); /* amber — caution states */
22
+ --danger: oklch(0.62 0.16 25); /* red-orange — errors */
23
+ --border: oklch(0.22 0 0); /* subtle dividers */
24
+ }
25
+ ```
26
+
27
+ ## Typography
28
+
29
+ - **Body/UI**: system-ui, sans-serif
30
+ - **Code/keys/endpoints**: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
31
+ - **Scale**: tight, functional. Page title is the only large type; everything else is small-to-base.
32
+
33
+ ## Layout
34
+
35
+ - Single-column centered container, max-width 720px.
36
+ - Generous vertical rhythm between sections.
37
+ - Cards are rare; prefer grouped rows with clear labels.
38
+ - One prominent hero section at top: status indicator + endpoint URL.
39
+
40
+ ## Components
41
+
42
+ - **Status pill**: small rounded badge with dot + label.
43
+ - **Key row**: label + masked key + copy/visibility toggles.
44
+ - **Provider card**: compact block with provider name, configured flag, sample model.
45
+ - **Code block**: dark panel with monospace text and copy button.
46
+
47
+ ## Motion
48
+
49
+ Low intensity. Subtle hover transitions on interactive elements (150ms ease-out). No entrance animations that gate content. Respect `prefers-reduced-motion`.
PRODUCT.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Product
2
+
3
+ ## Register
4
+
5
+ product
6
+
7
+ ## Users
8
+
9
+ A single technical user (the owner) running this proxy locally or on cheap personal hosting. They are comfortable editing `.env`, reading logs, and pointing client apps at a custom base URL. Context: personal experimentation, not production operations.
10
+
11
+ ## Product Purpose
12
+
13
+ A lightweight OpenAI-compatible API gateway that forwards chat/completion requests to the right AI provider based on which `sk-` proxy key the client uses. One server, one set of routes, multiple backends (NVIDIA NIM, Google AI Studio, extensible to more). It makes apps that only accept OpenAI-style keys work with non-OpenAI providers.
14
+
15
+ ## Brand Personality
16
+
17
+ Minimal, capable, unobtrusive. The interface should feel like a well-organized shelf — everything in its place, nothing shouting.
18
+
19
+ ## Anti-references
20
+
21
+ - Generic admin dashboards with dense data tables and sidebar navigation.
22
+ - AI-purple gradients, neon glows, or glassmorphism used decoratively.
23
+ - Bootstrap-style gray cards with identical spacing repeated endlessly.
24
+ - Landing-page hero sections with big marketing copy.
25
+
26
+ ## Design Principles
27
+
28
+ 1. **Clarity first**: the most important state (is the proxy running? which providers are configured?) should be visible at a glance.
29
+ 2. **No noise**: show only what the owner needs to verify setup. No analytics charts, no user management, no feature lists.
30
+ 3. **Respect the user's time**: copy-paste ready examples, masked keys, one-click visibility toggles.
31
+ 4. **Information should be scannable**: use typography and spacing to group related facts, not borders and boxes.
32
+ 5. **Personal, not corporate**: this is a single-user tool; the UI can be calm and opinionated rather than neutral-by-committee.
33
+
34
+ ## Accessibility & Inclusion
35
+
36
+ WCAG AA contrast as a floor. Keyboard navigable. Respects `prefers-reduced-motion`. No essential information conveyed by color alone.
config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import secrets
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ ROOT = Path(__file__).parent
10
+
11
+ HOST = os.getenv("HOST", "127.0.0.1")
12
+ PORT = int(os.getenv("PORT", "8000"))
13
+ POLL_INTERVAL = float(os.getenv("POLL_INTERVAL_SECONDS", "2"))
14
+ POLL_MAX_ATTEMPTS = int(os.getenv("POLL_MAX_ATTEMPTS", "60"))
15
+
16
+ PROVIDER_KEYS = {
17
+ "nvidia": os.getenv("SK_NVIDIA_KEY", ""),
18
+ "google": os.getenv("SK_GOOGLE_KEY", ""),
19
+ }
20
+
21
+ REAL_KEYS = {
22
+ "nvidia": os.getenv("REAL_NVIDIA_KEY", ""),
23
+ "google": os.getenv("REAL_GOOGLE_KEY", ""),
24
+ }
25
+
26
+
27
+ def _strip_prefix(key: str, prefix: str) -> str:
28
+ return key[len(prefix):] if key.startswith(prefix) else key
29
+
30
+
31
+ def generate_proxy_key(provider: str) -> str:
32
+ return f"sk-{provider}-" + secrets.token_urlsafe(32)
main.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import uvicorn
3
+
4
+ from config import HOST, PORT
5
+ from proxy.router import create_app
6
+
7
+ logging.basicConfig(
8
+ level=logging.INFO,
9
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
10
+ )
11
+
12
+ app = create_app()
13
+
14
+ if __name__ == "__main__":
15
+ uvicorn.run("main:app", host=HOST, port=PORT, reload=False)
proxy/__init__.py ADDED
File without changes
proxy/auth.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException, Request
2
+
3
+ from config import PROVIDER_KEYS
4
+ from proxy.providers import PROVIDERS, Provider
5
+
6
+
7
+ def _match_provider(token: str) -> tuple[str, str] | None:
8
+ """Return (provider_name, configured_proxy_key) if token matches a known provider key."""
9
+ for name, configured_key in PROVIDER_KEYS.items():
10
+ if configured_key and token == configured_key:
11
+ return name, configured_key
12
+ return None
13
+
14
+
15
+ def resolve_provider(request: Request) -> Provider:
16
+ auth = request.headers.get("Authorization", "")
17
+ parts = auth.split(" ", 1)
18
+ if len(parts) != 2 or parts[0].lower() != "bearer":
19
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header. Expected 'Bearer sk-{provider}-...'")
20
+
21
+ token = parts[1]
22
+ match = _match_provider(token)
23
+ if match is None:
24
+ raise HTTPException(status_code=401, detail="Unknown proxy key")
25
+
26
+ provider_name, _ = match
27
+ return PROVIDERS[provider_name]
28
+
29
+
30
+ def get_real_key(provider: Provider) -> str:
31
+ import os
32
+ key = os.getenv(provider.env_real_key, "")
33
+ if not key:
34
+ raise HTTPException(
35
+ status_code=500,
36
+ detail=f"Provider '{provider.name}' is configured in .env but its real API key ({provider.env_real_key}) is missing"
37
+ )
38
+ return key
proxy/client.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from typing import Any, AsyncIterator
5
+
6
+ import httpx
7
+ from fastapi import HTTPException
8
+ from fastapi.responses import StreamingResponse
9
+ from starlette.requests import Request
10
+
11
+ from config import POLL_INTERVAL, POLL_MAX_ATTEMPTS
12
+ from proxy.providers import Provider
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _TIMEOUT = httpx.Timeout(10.0, connect=10.0, read=180.0)
17
+
18
+
19
+ def _build_headers(provider: Provider, real_key: str, incoming_headers: dict[str, str]) -> dict[str, str]:
20
+ headers = {k: v for k, v in incoming_headers.items() if k.lower() not in ("host", "authorization", "content-length")}
21
+ headers[provider.auth_header] = provider.auth_value(real_key)
22
+ headers.setdefault("Accept", "application/json")
23
+ headers.setdefault("Content-Type", "application/json")
24
+ return headers
25
+
26
+
27
+ def _extract_request_id(response: httpx.Response) -> str | None:
28
+ try:
29
+ data = response.json()
30
+ if isinstance(data, dict):
31
+ return data.get("requestId") or data.get("id") or data.get("request_id")
32
+ except Exception:
33
+ pass
34
+ return response.headers.get("Location", "").rstrip("/").split("/")[-1] or None
35
+
36
+
37
+ async def _poll_nvidia(client: httpx.AsyncClient, provider: Provider, request_id: str) -> httpx.Response:
38
+ status_url = f"{provider.base_url}/v1/status/{request_id}"
39
+ logger.info("NVIDIA returned 202; polling %s", status_url)
40
+
41
+ for attempt in range(1, POLL_MAX_ATTEMPTS + 1):
42
+ await asyncio.sleep(POLL_INTERVAL)
43
+ poll_resp = await client.get(status_url, timeout=_TIMEOUT)
44
+ logger.debug("Poll attempt %d: status %d", attempt, poll_resp.status_code)
45
+
46
+ if poll_resp.status_code == 200:
47
+ return poll_resp
48
+ if poll_resp.status_code == 202:
49
+ continue
50
+ poll_resp.raise_for_status()
51
+
52
+ raise HTTPException(status_code=504, detail=f"NVIDIA async job {request_id} timed out after polling")
53
+
54
+
55
+ async def _stream_chunks(response: httpx.Response) -> AsyncIterator[bytes]:
56
+ async for chunk in response.aiter_raw():
57
+ yield chunk
58
+
59
+
60
+ async def forward_request(provider: Provider, real_key: str, request: Request, body: dict[str, Any]) -> Any:
61
+ target_url = f"{provider.base_url}/v1/chat/completions"
62
+ headers = _build_headers(provider, real_key, dict(request.headers))
63
+ stream = bool(body.get("stream"))
64
+
65
+ async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client:
66
+ try:
67
+ upstream = await client.post(target_url, headers=headers, json=body, stream=stream)
68
+ except httpx.TimeoutException as exc:
69
+ logger.warning("Upstream timeout for %s: %s", provider.name, exc)
70
+ raise HTTPException(status_code=504, detail="Upstream provider timed out")
71
+ except httpx.RequestError as exc:
72
+ logger.warning("Upstream request error for %s: %s", provider.name, exc)
73
+ raise HTTPException(status_code=502, detail=f"Could not reach provider '{provider.name}': {exc}")
74
+
75
+ if provider.handles_202 and upstream.status_code == 202:
76
+ request_id = _extract_request_id(upstream)
77
+ if not request_id:
78
+ raise HTTPException(status_code=502, detail="Provider returned 202 but no requestId was found")
79
+ upstream = await _poll_nvidia(client, provider, request_id)
80
+
81
+ if upstream.status_code >= 400:
82
+ content = await upstream.aread()
83
+ logger.warning("Upstream error from %s: %d %s", provider.name, upstream.status_code, content[:500])
84
+ raise HTTPException(status_code=upstream.status_code, detail=content.decode("utf-8", errors="replace"))
85
+
86
+ if stream:
87
+ return StreamingResponse(
88
+ _stream_chunks(upstream),
89
+ status_code=upstream.status_code,
90
+ media_type=upstream.headers.get("content-type", "text/event-stream"),
91
+ headers={k: v for k, v in upstream.headers.items() if k.lower() in ("cache-control", "x-request-id")},
92
+ )
93
+
94
+ return {
95
+ "status_code": upstream.status_code,
96
+ "headers": dict(upstream.headers),
97
+ "content": upstream.content,
98
+ }
proxy/providers.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Callable
3
+
4
+
5
+ @dataclass
6
+ class Provider:
7
+ name: str
8
+ base_url: str
9
+ auth_header: str
10
+ auth_value: Callable[[str], str]
11
+ handles_202: bool
12
+ env_real_key: str
13
+
14
+
15
+ PROVIDERS: dict[str, Provider] = {
16
+ "nvidia": Provider(
17
+ name="nvidia",
18
+ base_url="https://integrate.api.nvidia.com",
19
+ auth_header="Authorization",
20
+ auth_value=lambda key: f"Bearer {key}",
21
+ handles_202=True,
22
+ env_real_key="REAL_NVIDIA_KEY",
23
+ ),
24
+ "google": Provider(
25
+ name="google",
26
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai",
27
+ auth_header="x-goog-api-key",
28
+ auth_value=lambda key: key,
29
+ handles_202=False,
30
+ env_real_key="REAL_GOOGLE_KEY",
31
+ ),
32
+ }
proxy/router.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Depends, FastAPI, Request
6
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
7
+ from fastapi.staticfiles import StaticFiles
8
+ from starlette.exceptions import HTTPException as StarletteHTTPException
9
+
10
+ from config import HOST, PORT, PROVIDER_KEYS, ROOT
11
+ from proxy.auth import get_real_key, resolve_provider
12
+ from proxy.client import forward_request
13
+ from proxy.providers import PROVIDERS
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ api = APIRouter(prefix="/v1")
18
+
19
+
20
+ MODELS = {
21
+ "nvidia": [
22
+ "deepseek-ai/deepseek-v4-pro",
23
+ "deepseek-ai/deepseek-v4-flash",
24
+ "moonshotai/kimi-k2.6",
25
+ "moonshotai/kimi-k2.5",
26
+ "google/gemma-4-31b-it",
27
+ "google/gemma-3-27b-it",
28
+ ],
29
+ "google": [
30
+ "gemini-2.0-flash-001",
31
+ "gemini-2.0-flash-lite-001",
32
+ "gemini-1.5-pro-001",
33
+ "gemini-1.5-flash-001",
34
+ ],
35
+ }
36
+
37
+
38
+ @api.get("/models")
39
+ async def list_models() -> JSONResponse:
40
+ data = {
41
+ "object": "list",
42
+ "data": [
43
+ {
44
+ "id": model,
45
+ "object": "model",
46
+ "owned_by": provider,
47
+ }
48
+ for provider, models in MODELS.items()
49
+ for model in models
50
+ ],
51
+ }
52
+ return JSONResponse(content=data)
53
+
54
+
55
+ @api.post("/chat/completions")
56
+ async def chat_completions(request: Request) -> Response:
57
+ provider = resolve_provider(request)
58
+ real_key = get_real_key(provider)
59
+
60
+ try:
61
+ body = await request.json()
62
+ except Exception as exc:
63
+ raise StarletteHTTPException(status_code=400, detail=f"Invalid JSON body: {exc}") from exc
64
+
65
+ if not isinstance(body, dict):
66
+ raise StarletteHTTPException(status_code=400, detail="Request body must be a JSON object")
67
+
68
+ logger.info("Proxying request to provider=%s model=%s", provider.name, body.get("model", "default"))
69
+
70
+ result = await forward_request(provider, real_key, request, body)
71
+
72
+ if isinstance(result, Response):
73
+ return result
74
+
75
+ return Response(
76
+ content=result["content"],
77
+ status_code=result["status_code"],
78
+ headers={k: v for k, v in result["headers"].items() if k.lower() not in ("content-encoding", "transfer-encoding", "content-length")},
79
+ )
80
+
81
+
82
+ @api.get("/health")
83
+ async def health() -> JSONResponse:
84
+ return JSONResponse({"status": "ok", "providers": list(PROVIDERS.keys())})
85
+
86
+
87
+ @api.get("/keys")
88
+ async def proxy_keys() -> JSONResponse:
89
+ masked = {}
90
+ for name, key in PROVIDER_KEYS.items():
91
+ if key:
92
+ visible = key[:8] + "..." if len(key) > 11 else key
93
+ masked[name] = visible
94
+ return JSONResponse(masked)
95
+
96
+
97
+ def _dashboard_html() -> str:
98
+ try:
99
+ return (ROOT / "static" / "index.html").read_text(encoding="utf-8")
100
+ except FileNotFoundError:
101
+ return "<h1>Dashboard not built yet</h1>"
102
+
103
+
104
+ def create_app() -> FastAPI:
105
+ app = FastAPI(title="OpenAI-Compatible API Proxy", version="1.0.0")
106
+
107
+ static_dir = ROOT / "static"
108
+ if static_dir.exists():
109
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
110
+
111
+ app.include_router(api)
112
+
113
+ @app.get("/", response_class=HTMLResponse)
114
+ async def dashboard() -> str:
115
+ return _dashboard_html()
116
+
117
+ @app.exception_handler(StarletteHTTPException)
118
+ async def http_exception_handler(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
119
+ return JSONResponse(status_code=exc.status_code, content={"error": {"message": exc.detail, "type": "proxy_error"}})
120
+
121
+ return app
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.29.0
3
+ httpx>=0.27.0
4
+ python-dotenv>=1.0.0
static/index.html ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>API Proxy</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ html { -webkit-font-smoothing: antialiased; }
10
+
11
+ :root {
12
+ --bg: oklch(0.08 0 0);
13
+ --surface: oklch(0.11 0 0);
14
+ --surface-2: oklch(0.15 0 0);
15
+ --ink: oklch(0.94 0 0);
16
+ --muted: oklch(0.58 0 0);
17
+ --faint: oklch(0.32 0 0);
18
+ --border: oklch(0.20 0 0);
19
+ --primary: oklch(0.68 0.13 38);
20
+ --primary-text: oklch(1 0 0);
21
+ --accent: oklch(0.68 0.12 175);
22
+ --warning: oklch(0.74 0.12 85);
23
+ --danger: oklch(0.62 0.16 25);
24
+ }
25
+
26
+ body {
27
+ background: var(--bg);
28
+ color: var(--ink);
29
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
30
+ font-size: 14px;
31
+ line-height: 1.5;
32
+ }
33
+
34
+ .page {
35
+ max-width: 680px;
36
+ margin: 0 auto;
37
+ padding: 48px 24px 80px;
38
+ }
39
+
40
+ /* --- Section headers --- */
41
+ .section-header {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 10px;
45
+ margin-bottom: 20px;
46
+ }
47
+ .section-label {
48
+ font-size: 11px;
49
+ font-weight: 600;
50
+ letter-spacing: 0.06em;
51
+ text-transform: uppercase;
52
+ color: var(--muted);
53
+ }
54
+
55
+ /* --- Hero --- */
56
+ .hero {
57
+ padding: 36px 0 48px;
58
+ }
59
+ .hero-status {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 8px;
63
+ font-size: 13px;
64
+ font-weight: 500;
65
+ padding: 6px 14px;
66
+ border-radius: 20px;
67
+ margin-bottom: 24px;
68
+ background: var(--surface);
69
+ border: 1px solid var(--border);
70
+ transition: background 150ms ease;
71
+ }
72
+ .hero-status .dot {
73
+ width: 8px;
74
+ height: 8px;
75
+ border-radius: 50%;
76
+ background: var(--accent);
77
+ }
78
+ .hero-status .dot.offline { background: var(--danger); }
79
+ .hero-status .dot.checking { background: var(--warning); }
80
+ .hero-title {
81
+ font-size: 28px;
82
+ font-weight: 600;
83
+ letter-spacing: -0.02em;
84
+ line-height: 1.2;
85
+ margin-bottom: 10px;
86
+ }
87
+ .hero-sub {
88
+ font-size: 14px;
89
+ color: var(--muted);
90
+ max-width: 48ch;
91
+ }
92
+
93
+ /* --- Endpoint --- */
94
+ .endpoint {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 10px;
98
+ margin-top: 28px;
99
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
100
+ font-size: 13px;
101
+ padding: 12px 16px;
102
+ background: var(--surface);
103
+ border: 1px solid var(--border);
104
+ border-radius: 8px;
105
+ word-break: break-all;
106
+ }
107
+ .endpoint .method {
108
+ font-weight: 600;
109
+ color: var(--primary);
110
+ font-size: 11px;
111
+ letter-spacing: 0.04em;
112
+ flex-shrink: 0;
113
+ }
114
+ .endpoint .path {
115
+ color: var(--ink);
116
+ opacity: 0.85;
117
+ }
118
+ .endpoint .copy-btn {
119
+ margin-left: auto;
120
+ flex-shrink: 0;
121
+ background: var(--surface-2);
122
+ border: 1px solid var(--border);
123
+ color: var(--muted);
124
+ cursor: pointer;
125
+ font-size: 11px;
126
+ font-weight: 500;
127
+ padding: 4px 10px;
128
+ border-radius: 5px;
129
+ font-family: system-ui, sans-serif;
130
+ transition: background 150ms ease, color 150ms ease;
131
+ }
132
+ .endpoint .copy-btn:hover {
133
+ background: var(--faint);
134
+ color: var(--ink);
135
+ }
136
+ .endpoint .copy-btn.copied {
137
+ color: var(--accent);
138
+ }
139
+
140
+ /* --- Provider cards --- */
141
+ .provider-grid {
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 10px;
145
+ }
146
+ .provider-card {
147
+ background: var(--surface);
148
+ border: 1px solid var(--border);
149
+ border-radius: 10px;
150
+ padding: 18px 20px;
151
+ display: flex;
152
+ align-items: flex-start;
153
+ justify-content: space-between;
154
+ gap: 16px;
155
+ transition: background 150ms ease, border-color 150ms ease;
156
+ }
157
+ .provider-card:hover {
158
+ background: var(--surface-2);
159
+ border-color: var(--faint);
160
+ }
161
+ .provider-left {
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: 6px;
165
+ min-width: 0;
166
+ }
167
+ .provider-name {
168
+ font-size: 15px;
169
+ font-weight: 600;
170
+ letter-spacing: -0.01em;
171
+ }
172
+ .provider-models {
173
+ font-size: 12px;
174
+ color: var(--muted);
175
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
176
+ word-break: break-word;
177
+ line-height: 1.6;
178
+ }
179
+ .provider-right {
180
+ display: flex;
181
+ flex-direction: column;
182
+ align-items: flex-end;
183
+ gap: 6px;
184
+ flex-shrink: 0;
185
+ }
186
+ .provider-status {
187
+ font-size: 11px;
188
+ font-weight: 600;
189
+ letter-spacing: 0.04em;
190
+ text-transform: uppercase;
191
+ padding: 4px 10px;
192
+ border-radius: 12px;
193
+ background: var(--surface-2);
194
+ color: var(--muted);
195
+ border: 1px solid var(--border);
196
+ }
197
+ .provider-status.configured {
198
+ color: var(--accent);
199
+ border-color: oklch(0.30 0.05 175);
200
+ background: oklch(0.15 0.02 175);
201
+ }
202
+
203
+ /* --- Key row --- */
204
+ .key-section {
205
+ display: flex;
206
+ flex-direction: column;
207
+ gap: 8px;
208
+ }
209
+ .key-row {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 10px;
213
+ background: var(--surface);
214
+ border: 1px solid var(--border);
215
+ border-radius: 8px;
216
+ padding: 12px 14px;
217
+ }
218
+ .key-provider-label {
219
+ font-size: 12px;
220
+ font-weight: 600;
221
+ letter-spacing: 0.03em;
222
+ text-transform: uppercase;
223
+ color: var(--muted);
224
+ min-width: 72px;
225
+ flex-shrink: 0;
226
+ }
227
+ .key-value {
228
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
229
+ font-size: 13px;
230
+ color: var(--ink);
231
+ opacity: 0.7;
232
+ min-width: 0;
233
+ overflow: hidden;
234
+ text-overflow: ellipsis;
235
+ white-space: nowrap;
236
+ user-select: all;
237
+ }
238
+ .key-value.masked {
239
+ opacity: 0.4;
240
+ filter: blur(4px);
241
+ transition: filter 200ms ease, opacity 200ms ease;
242
+ user-select: none;
243
+ }
244
+ .key-toggle {
245
+ flex-shrink: 0;
246
+ background: none;
247
+ border: 1px solid var(--border);
248
+ color: var(--muted);
249
+ cursor: pointer;
250
+ font-size: 11px;
251
+ font-weight: 500;
252
+ padding: 4px 10px;
253
+ border-radius: 5px;
254
+ font-family: system-ui, sans-serif;
255
+ transition: background 150ms ease, color 150ms ease;
256
+ }
257
+ .key-toggle:hover { background: var(--surface-2); color: var(--ink); }
258
+
259
+ /* --- Code blocks --- */
260
+ .code-section {
261
+ margin-top: 48px;
262
+ }
263
+ .code-block {
264
+ background: var(--surface);
265
+ border: 1px solid var(--border);
266
+ border-radius: 10px;
267
+ padding: 16px 18px;
268
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
269
+ font-size: 13px;
270
+ line-height: 1.7;
271
+ color: var(--ink);
272
+ overflow-x: auto;
273
+ position: relative;
274
+ margin-bottom: 10px;
275
+ }
276
+ .code-block .c { color: var(--muted); }
277
+ .code-block .k { color: var(--primary); }
278
+ .code-block .v { color: var(--accent); }
279
+ .code-copy {
280
+ position: absolute;
281
+ top: 10px;
282
+ right: 10px;
283
+ background: var(--surface-2);
284
+ border: 1px solid var(--border);
285
+ color: var(--muted);
286
+ cursor: pointer;
287
+ font-size: 11px;
288
+ font-weight: 500;
289
+ padding: 4px 10px;
290
+ border-radius: 5px;
291
+ font-family: system-ui, sans-serif;
292
+ transition: background 150ms ease, color 150ms ease;
293
+ }
294
+ .code-copy:hover { background: var(--faint); color: var(--ink); }
295
+ .code-copy.copied { color: var(--accent); }
296
+
297
+ /* --- Divider --- */
298
+ .divider {
299
+ border: none;
300
+ border-top: 1px solid var(--border);
301
+ margin: 40px 0;
302
+ }
303
+
304
+ /* --- Responsive --- */
305
+ @media (max-width: 520px) {
306
+ .page { padding: 32px 16px 60px; }
307
+ .hero-title { font-size: 24px; }
308
+ .provider-card {
309
+ flex-direction: column;
310
+ align-items: flex-start;
311
+ }
312
+ .provider-right {
313
+ flex-direction: row;
314
+ width: 100%;
315
+ }
316
+ .key-row { flex-wrap: wrap; }
317
+ }
318
+
319
+ @media (prefers-reduced-motion: reduce) {
320
+ *, *::before, *::after { transition: none !important; }
321
+ }
322
+ </style>
323
+ </head>
324
+ <body>
325
+ <div class="page">
326
+
327
+ <!-- Hero -->
328
+ <header class="hero">
329
+ <div class="hero-status" id="status-badge">
330
+ <span class="dot checking"></span>
331
+ <span id="status-text">checking</span>
332
+ </div>
333
+ <h1 class="hero-title">API Proxy</h1>
334
+ <p class="hero-sub">
335
+ A personal gateway that routes OpenAI-compatible chat requests to NVIDIA NIM and Google AI Studio — based on which <code>sk-</code> key your app uses.
336
+ </p>
337
+ <div class="endpoint">
338
+ <span class="method">POST</span>
339
+ <span class="path" id="endpoint-url">http://localhost:8000/v1/chat/completions</span>
340
+ <button class="copy-btn" data-copy="endpoint-url">Copy URL</button>
341
+ </div>
342
+ </header>
343
+
344
+ <!-- Providers -->
345
+ <section>
346
+ <div class="section-header">
347
+ <span class="section-label">Providers</span>
348
+ </div>
349
+ <div class="provider-grid" id="provider-list">
350
+ <div class="provider-card" data-provider="nvidia">
351
+ <div class="provider-left">
352
+ <span class="provider-name">NVIDIA NIM</span>
353
+ <span class="provider-models">deepseek-ai/deepseek-v4-flash, moonshotai/kimi-k2.6, google/gemma-4-31b-it</span>
354
+ </div>
355
+ <div class="provider-right">
356
+ <span class="provider-status" id="nvidia-status">checking</span>
357
+ </div>
358
+ </div>
359
+ <div class="provider-card" data-provider="google">
360
+ <div class="provider-left">
361
+ <span class="provider-name">Google AI Studio</span>
362
+ <span class="provider-models">gemini-2.0-flash-001, gemini-1.5-pro-001, gemini-1.5-flash-001</span>
363
+ </div>
364
+ <div class="provider-right">
365
+ <span class="provider-status" id="google-status">checking</span>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ </section>
370
+
371
+ <hr class="divider">
372
+
373
+ <!-- Keys -->
374
+ <section>
375
+ <div class="section-header">
376
+ <span class="section-label">Proxy keys</span>
377
+ </div>
378
+ <div class="key-section" id="key-list">
379
+ <div class="key-row">
380
+ <span class="key-provider-label">NVIDIA</span>
381
+ <span class="key-value masked" id="key-nvidia" data-full="">loading...</span>
382
+ <button class="key-toggle" data-key="key-nvidia">Show</button>
383
+ </div>
384
+ <div class="key-row">
385
+ <span class="key-provider-label">GOOGLE</span>
386
+ <span class="key-value masked" id="key-google" data-full="">loading...</span>
387
+ <button class="key-toggle" data-key="key-google">Show</button>
388
+ </div>
389
+ </div>
390
+ </section>
391
+
392
+ <!-- Usage examples -->
393
+ <div class="code-section">
394
+ <div class="section-header">
395
+ <span class="section-label">Usage</span>
396
+ </div>
397
+ <div class="code-block" id="curl-nvidia">
398
+ <button class="code-copy" data-copy="curl-nvidia">Copy</button>
399
+ <span class="c"># NVIDIA (deepseek-v4-flash)</span><br>
400
+ curl <span class="v">http://localhost:8000/v1/chat/completions</span> \<br>
401
+ &nbsp;&nbsp;-H <span class="v">"Content-Type: application/json"</span> \<br>
402
+ &nbsp;&nbsp;-H <span class="v">"Authorization: Bearer <span id="curl-nv-key">sk-nvidia-...</span>"</span> \<br>
403
+ &nbsp;&nbsp;-d <span class="v">'{"model":"deepseek-ai/deepseek-v4-flash","messages":[{"role":"user","content":"hello"}]}'</span>
404
+ </div>
405
+ <div class="code-block" id="curl-google">
406
+ <button class="code-copy" data-copy="curl-google">Copy</button>
407
+ <span class="c"># Google (gemini-2.0-flash)</span><br>
408
+ curl <span class="v">http://localhost:8000/v1/chat/completions</span> \<br>
409
+ &nbsp;&nbsp;-H <span class="v">"Content-Type: application/json"</span> \<br>
410
+ &nbsp;&nbsp;-H <span class="v">"Authorization: Bearer <span id="curl-gc-key">sk-google-...</span>"</span> \<br>
411
+ &nbsp;&nbsp;-d <span class="v">'{"model":"gemini-2.0-flash-001","messages":[{"role":"user","content":"hello"}]}'</span>
412
+ </div>
413
+ </div>
414
+
415
+ </div>
416
+
417
+ <script>
418
+ const HOST = window.location.host;
419
+
420
+ function copyText(el, text) {
421
+ navigator.clipboard.writeText(text).then(() => {
422
+ el.classList.add('copied');
423
+ el.textContent = 'Copied';
424
+ setTimeout(() => {
425
+ el.classList.remove('copied');
426
+ el.textContent = 'Copy URL';
427
+ }, 1800);
428
+ });
429
+ }
430
+
431
+ document.querySelectorAll('[data-copy]').forEach(btn => {
432
+ btn.addEventListener('click', () => {
433
+ const id = btn.dataset.copy;
434
+ if (id === 'endpoint-url') {
435
+ const url = document.getElementById('endpoint-url').textContent;
436
+ copyText(btn, url);
437
+ } else {
438
+ const block = document.getElementById(id);
439
+ const text = block.textContent.replace(/^\s+/gm, '') || '';
440
+ copyText(btn, text.trim());
441
+ }
442
+ });
443
+ });
444
+
445
+ // Key visibility toggle
446
+ document.querySelectorAll('.key-toggle').forEach(btn => {
447
+ btn.addEventListener('click', () => {
448
+ const keyId = btn.dataset.key;
449
+ const el = document.getElementById(keyId);
450
+ if (el.classList.contains('masked')) {
451
+ el.classList.remove('masked');
452
+ btn.textContent = 'Hide';
453
+ } else {
454
+ el.classList.add('masked');
455
+ btn.textContent = 'Show';
456
+ }
457
+ });
458
+ });
459
+
460
+ // Health check
461
+ async function checkHealth() {
462
+ const badge = document.getElementById('status-badge');
463
+ const text = document.getElementById('status-text');
464
+ const dot = badge.querySelector('.dot');
465
+
466
+ try {
467
+ const res = await fetch('/v1/health');
468
+ if (res.ok) {
469
+ dot.className = 'dot';
470
+ text.textContent = 'running';
471
+ return true;
472
+ }
473
+ } catch {}
474
+ dot.className = 'dot offline';
475
+ text.textContent = 'unreachable';
476
+ return false;
477
+ }
478
+
479
+ // Key info
480
+ async function loadKeys() {
481
+ try {
482
+ const res = await fetch('/v1/health');
483
+ const data = await res.json();
484
+ const providers = data.providers || [];
485
+
486
+ const nvKey = document.getElementById('key-nvidia');
487
+ const gcKey = document.getElementById('key-google');
488
+ const curlNvKey = document.getElementById('curl-nv-key');
489
+ const curlGcKey = document.getElementById('curl-gc-key');
490
+
491
+ // Update status badges
492
+ if (providers.includes('nvidia')) {
493
+ const s = document.getElementById('nvidia-status');
494
+ s.textContent = 'configured';
495
+ s.classList.add('configured');
496
+ }
497
+ if (providers.includes('google')) {
498
+ const s = document.getElementById('google-status');
499
+ s.textContent = 'configured';
500
+ s.classList.add('configured');
501
+ }
502
+
503
+ // Keys from server
504
+ try {
505
+ const ks = await fetch('/v1/keys');
506
+ if (ks.ok) {
507
+ const kd = await ks.json();
508
+ if (kd.nvidia) {
509
+ const nv = kd.nvidia;
510
+ nvKey.textContent = nv;
511
+ nvKey.dataset.full = nv;
512
+ curlNvKey.textContent = nv;
513
+ }
514
+ if (kd.google) {
515
+ const gc = kd.google;
516
+ gcKey.textContent = gc;
517
+ gcKey.dataset.full = gc;
518
+ curlGcKey.textContent = gc;
519
+ }
520
+ }
521
+ } catch {}
522
+ } catch {
523
+ document.getElementById('nvidia-status').textContent = 'offline';
524
+ document.getElementById('google-status').textContent = 'offline';
525
+ }
526
+ }
527
+
528
+ // Update endpoint URL
529
+ document.getElementById('endpoint-url').textContent =
530
+ window.location.origin + '/v1/chat/completions';
531
+
532
+ checkHealth();
533
+ loadKeys();
534
+ </script>
535
+ </body>
536
+ </html>