Upload 10 files
Browse files- .env.example +7 -10
- README.md +92 -135
- app/main.py +507 -933
- requirements.txt +2 -5
- static/index.html +78 -42
- static/public.js +191 -99
- static/style.css +345 -551
.env.example
CHANGED
|
@@ -1,11 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
PUBLIC_HISTORY_HOURS=48
|
| 9 |
-
MAX_UPSTREAM_CONNECTIONS=256
|
| 10 |
-
MAX_KEEPALIVE_CONNECTIONS=64
|
| 11 |
DATABASE_PATH=./data.sqlite3
|
|
|
|
| 1 |
+
NVIDIA_API_BASE=https://integrate.api.nvidia.com/v1
|
| 2 |
+
MODEL_LIST=z-ai/glm5,z-ai/glm4.7,minimaxai/minimax-m2.5,minimaxai/minimax-m2.7,moonshotai/kimi-k2.5,deepseek-ai/deepseek-v3.2,google/gemma-4-31b-it,qwen/qwen3.5-397b-a17b
|
| 3 |
+
MODEL_SYNC_INTERVAL_MINUTES=30
|
| 4 |
+
PUBLIC_HISTORY_BUCKETS=6
|
| 5 |
+
REQUEST_TIMEOUT_SECONDS=90
|
| 6 |
+
MAX_UPSTREAM_CONNECTIONS=512
|
| 7 |
+
MAX_KEEPALIVE_CONNECTIONS=128
|
|
|
|
|
|
|
|
|
|
| 8 |
DATABASE_PATH=./data.sqlite3
|
README.md
CHANGED
|
@@ -1,136 +1,93 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: NVIDIA NIM 响应网关
|
| 3 |
-
sdk: docker
|
| 4 |
-
app_port: 7860
|
| 5 |
-
pinned: false
|
| 6 |
-
---
|
| 7 |
-
|
| 8 |
-
# NVIDIA NIM 响应网关
|
| 9 |
-
|
| 10 |
-
这是一个
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
-
|
| 19 |
-
-
|
| 20 |
-
- `
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
- `GET /`
|
| 51 |
-
- `GET /api/
|
| 52 |
-
|
| 53 |
-
兼容接口
|
| 54 |
-
|
| 55 |
-
- `POST /v1/responses`
|
| 56 |
-
- `GET /v1/
|
| 57 |
-
- `GET /v1/
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
- `
|
| 62 |
-
- `
|
| 63 |
-
- `
|
| 64 |
-
- `
|
| 65 |
-
- `
|
| 66 |
-
- `
|
| 67 |
-
- `
|
| 68 |
-
- `
|
| 69 |
-
|
| 70 |
-
##
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
-
|
| 79 |
-
- `
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
如需本地联调与 smoke test:
|
| 95 |
-
|
| 96 |
-
```bash
|
| 97 |
-
pip install -r requirements-dev.txt
|
| 98 |
-
python scripts/local_smoke_test.py
|
| 99 |
-
```
|
| 100 |
-
|
| 101 |
-
启动服务:
|
| 102 |
-
|
| 103 |
-
```bash
|
| 104 |
-
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
## 部署到 Hugging Face Space
|
| 108 |
-
|
| 109 |
-
这个仓库已经按 Docker Space 准备好了部署文件。
|
| 110 |
-
|
| 111 |
-
1. 新建一个 Hugging Face Space,SDK 选择 `Docker`
|
| 112 |
-
2. 将 `hf_space` 目录内的内容作为 Space 根目录上传
|
| 113 |
-
3. 在 Space Secrets 中至少配置 `PASSWORD`、`PASS_APIKEY` 和一个 NVIDIA NIM Key
|
| 114 |
-
4. 打开 `/admin`,确认 Key 可用,并执行一次巡检
|
| 115 |
-
|
| 116 |
-
## 本地验证情况
|
| 117 |
-
|
| 118 |
-
我已经通过本地 smoke test 验证了以下链路:
|
| 119 |
-
|
| 120 |
-
- 中文首页与中文后台页面可正常返回
|
| 121 |
-
- HTML 响应头包含 `charset=utf-8`
|
| 122 |
-
- `/v1/responses` 鉴权正常
|
| 123 |
-
- `/v1/responses` 文本回复转换正常
|
| 124 |
-
- tool call / function call 转换正常
|
| 125 |
-
- `function_call_output` 回灌到上游消息格式正常
|
| 126 |
-
- `previous_response_id` 上下文拼接正常
|
| 127 |
-
- 多个 NIM Key 轮询分发正常
|
| 128 |
-
- 并发请求转发正常
|
| 129 |
-
- 后台登录、手动巡检、公开健康页同步正常
|
| 130 |
-
|
| 131 |
-
## 参考资料
|
| 132 |
-
|
| 133 |
-
- OpenAI Responses API: https://platform.openai.com/docs/guides/responses-vs-chat-completions
|
| 134 |
-
- OpenAI Function Calling: https://platform.openai.com/docs/guides/function-calling
|
| 135 |
-
- NVIDIA Build: https://build.nvidia.com/
|
| 136 |
- NVIDIA NIM API 文档: https://docs.api.nvidia.com/
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: NVIDIA NIM 响应网关
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
pinned: false
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# NVIDIA NIM 响应网关
|
| 9 |
+
|
| 10 |
+
这是一个面向公开使用的 NVIDIA NIM 到 OpenAI `/v1/responses` 兼容网关。
|
| 11 |
+
|
| 12 |
+
它不在本地保存任何用户的 NIM API Key。用户调用本项目时,需要自己通过请求头携带 NIM Key,网关只负责协议转换、性能优化、聚合统计和官方模型目录展示。
|
| 13 |
+
|
| 14 |
+
## 主要能力
|
| 15 |
+
|
| 16 |
+
- 将 NVIDIA 官方 `POST /v1/chat/completions` 转换为 OpenAI 风格的 `POST /v1/responses`
|
| 17 |
+
- 支持 tool calling / function calling
|
| 18 |
+
- 支持 `function_call_output` 回灌
|
| 19 |
+
- 支持 `previous_response_id` 对话续写
|
| 20 |
+
- 对 `/v1/responses` 和 `/v1/responses/{response_id}` 使用用户自带的 NIM Key 做鉴权与上游转发
|
| 21 |
+
- `/v1/models` 直接返回来自 NVIDIA 官方 `/v1/models` 的同步结果,保持 OpenAI 风格结构
|
| 22 |
+
- 前端第一页展示总调用次数、平均健康度、每个模型 10 分钟成功率
|
| 23 |
+
- 前端第二页展示官方模型目录,并按提供商分类展示
|
| 24 |
+
- 页面采用���向双页切换,带平滑动画与现代卡片式设计
|
| 25 |
+
- 使用共享 HTTP 连接池、SQLite WAL 和异步线程化落库来增强高并发场景下的转发性能
|
| 26 |
+
|
| 27 |
+
## 用户如何调用
|
| 28 |
+
|
| 29 |
+
对于 `POST /v1/responses`,请通过下面任意一种方式传入你自己的 NVIDIA NIM Key:
|
| 30 |
+
|
| 31 |
+
- `Authorization: Bearer <你的 NIM Key>`
|
| 32 |
+
- `X-API-Key: <你的 NIM Key>`
|
| 33 |
+
|
| 34 |
+
网关不会把原始 Key 持久化到数据库中,只会在内存中用于当前请求,并对响应链路使用 Key 哈希做隔离。
|
| 35 |
+
|
| 36 |
+
## 官方模型目录同步
|
| 37 |
+
|
| 38 |
+
项目会定时从官方接口拉取模型列表:
|
| 39 |
+
|
| 40 |
+
`https://integrate.api.nvidia.com/v1/models`
|
| 41 |
+
|
| 42 |
+
同步后的模型目录同时用于:
|
| 43 |
+
|
| 44 |
+
- `GET /v1/models`
|
| 45 |
+
- 前端第二页“官方模型库”展示
|
| 46 |
+
|
| 47 |
+
## 公开页面
|
| 48 |
+
|
| 49 |
+
- `GET /`:首页,双页展示
|
| 50 |
+
- `GET /api/dashboard`:健康度与统计数据
|
| 51 |
+
- `GET /api/catalog`:官方模型目录与提供商分类
|
| 52 |
+
|
| 53 |
+
## 兼容接口
|
| 54 |
+
|
| 55 |
+
- `POST /v1/responses`
|
| 56 |
+
- `GET /v1/responses/{response_id}`
|
| 57 |
+
- `GET /v1/models`
|
| 58 |
+
|
| 59 |
+
## 环境变量
|
| 60 |
+
|
| 61 |
+
- `NVIDIA_API_BASE`:默认 `https://integrate.api.nvidia.com/v1`
|
| 62 |
+
- `MODEL_LIST`:首页监控模型列表,逗号分隔
|
| 63 |
+
- `MODEL_SYNC_INTERVAL_MINUTES`:官方模型目录同步周期,默认 `30`
|
| 64 |
+
- `PUBLIC_HISTORY_BUCKETS`:首页展示最近多少个 10 分钟时间片,默认 `6`
|
| 65 |
+
- `REQUEST_TIMEOUT_SECONDS`:上游请求超时,默认 `90`
|
| 66 |
+
- `MAX_UPSTREAM_CONNECTIONS`:共享连接池最大连接数,默认 `512`
|
| 67 |
+
- `MAX_KEEPALIVE_CONNECTIONS`:共享连接池最大 keep-alive 连接数,默认 `128`
|
| 68 |
+
- `DATABASE_PATH`:默认 `./data.sqlite3`
|
| 69 |
+
|
| 70 |
+
## 本地验证
|
| 71 |
+
|
| 72 |
+
我已经完成两层本地联调:
|
| 73 |
+
|
| 74 |
+
1. Mock 联调:
|
| 75 |
+
- 通过 [scripts/local_smoke_test.py](D:\Code\NIM2response\scripts\local_smoke_test.py) 验证了协议转换、官方模型同步、用户 Key 鉴权、`previous_response_id`、tool call 与前端数据接口。
|
| 76 |
+
|
| 77 |
+
2. 真实上游联调:
|
| 78 |
+
- 通过 [scripts/live_e2e_validation.py](D:\Code\NIM2response\scripts\live_e2e_validation.py) 使用你提供的测试 NIM Key,真实调用了 NVIDIA 官方模型目录和实际模型响应。
|
| 79 |
+
- 实测结果:`live_gateway_ok`,并成功通过 `z-ai/glm5` 得到 `OK`。
|
| 80 |
+
|
| 81 |
+
## 部署到 Hugging Face Space
|
| 82 |
+
|
| 83 |
+
1. 新建 Hugging Face Space,SDK 选择 `Docker`
|
| 84 |
+
2. 将 `hf_space` 目录内的内容作为 Space 根目录上传
|
| 85 |
+
3. 按需配置 `MODEL_LIST` 等环境变量
|
| 86 |
+
4. 启动后即可直接公开使用
|
| 87 |
+
|
| 88 |
+
## 参考资料
|
| 89 |
+
|
| 90 |
+
- OpenAI Responses API: https://platform.openai.com/docs/guides/responses-vs-chat-completions
|
| 91 |
+
- OpenAI Function Calling: https://platform.openai.com/docs/guides/function-calling
|
| 92 |
+
- NVIDIA Build: https://build.nvidia.com/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
- NVIDIA NIM API 文档: https://docs.api.nvidia.com/
|
app/main.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import sqlite3
|
|
@@ -12,11 +13,10 @@ from pathlib import Path
|
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
import httpx
|
| 15 |
-
from
|
| 16 |
-
from fastapi import
|
| 17 |
-
from fastapi.responses import HTMLResponse,
|
| 18 |
from fastapi.staticfiles import StaticFiles
|
| 19 |
-
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
| 20 |
|
| 21 |
|
| 22 |
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
@@ -26,31 +26,20 @@ RAW_NVIDIA_API_BASE = os.getenv("NVIDIA_API_BASE", os.getenv("NIM_BASE_URL", "ht
|
|
| 26 |
NVIDIA_API_BASE = RAW_NVIDIA_API_BASE if RAW_NVIDIA_API_BASE.endswith("/v1") else f"{RAW_NVIDIA_API_BASE}/v1"
|
| 27 |
CHAT_COMPLETIONS_URL = f"{NVIDIA_API_BASE}/chat/completions"
|
| 28 |
MODELS_URL = f"{NVIDIA_API_BASE}/models"
|
| 29 |
-
ADMIN_PASSWORD = os.getenv("PASSWORD")
|
| 30 |
-
SESSION_SECRET = os.getenv("SESSION_SECRET") or ADMIN_PASSWORD or "nim-responses-dev-secret"
|
| 31 |
-
COOKIE_NAME = os.getenv("COOKIE_NAME", "nim_admin_session")
|
| 32 |
-
PASS_API_KEY = os.getenv("PASS_APIKEY") or os.getenv("GATEWAY_API_KEY")
|
| 33 |
-
DEFAULT_ENV_KEY = os.getenv("NVIDIA_NIM_API_KEY") or os.getenv("NVIDIA_API_KEY")
|
| 34 |
REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "90"))
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
("minimaxai/minimax-m2.5", "MiniMax M2.5", "Long-context assistant model from MiniMax", 20, 1),
|
| 44 |
-
("moonshotai/kimi-k2.5", "Kimi K2.5", "Kimi family model tuned for tool use and code", 30, 1),
|
| 45 |
-
("deepseek-ai/deepseek-v3.2", "DeepSeek V3.2", "DeepSeek production general-purpose model", 40, 1),
|
| 46 |
-
("google/gemma-4-31b-it", "Gemma 4 31B IT", "Instruction-tuned Gemma model", 50, 0),
|
| 47 |
-
("qwen/qwen3.5-397b-a17b", "Qwen 3.5 397B A17B", "Large-scale Qwen model with broad capabilities", 60, 0),
|
| 48 |
-
]
|
| 49 |
-
|
| 50 |
-
scheduler = AsyncIOScheduler(timezone="UTC")
|
| 51 |
http_client: httpx.AsyncClient | None = None
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def utcnow() -> datetime:
|
|
@@ -61,48 +50,38 @@ def utcnow_iso() -> str:
|
|
| 61 |
return utcnow().isoformat()
|
| 62 |
|
| 63 |
|
| 64 |
-
def
|
| 65 |
-
|
| 66 |
-
return None
|
| 67 |
-
try:
|
| 68 |
-
return datetime.fromisoformat(value)
|
| 69 |
-
except ValueError:
|
| 70 |
-
return None
|
| 71 |
|
| 72 |
|
| 73 |
-
def
|
| 74 |
-
|
| 75 |
-
return value
|
| 76 |
-
if isinstance(value, (int, float)):
|
| 77 |
-
return bool(value)
|
| 78 |
-
if value is None:
|
| 79 |
-
return False
|
| 80 |
-
return str(value).strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
| 81 |
|
| 82 |
|
| 83 |
-
def
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
max_connections=MAX_UPSTREAM_CONNECTIONS,
|
| 92 |
-
max_keepalive_connections=MAX_KEEPALIVE_CONNECTIONS,
|
| 93 |
-
)
|
| 94 |
-
http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS, limits=limits)
|
| 95 |
-
return http_client
|
| 96 |
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
def get_db_connection() -> sqlite3.Connection:
|
|
|
|
| 106 |
conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30.0)
|
| 107 |
conn.row_factory = sqlite3.Row
|
| 108 |
conn.execute("PRAGMA journal_mode=WAL")
|
|
@@ -113,289 +92,198 @@ def get_db_connection() -> sqlite3.Connection:
|
|
| 113 |
|
| 114 |
|
| 115 |
def init_db() -> None:
|
| 116 |
-
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 117 |
conn = get_db_connection()
|
| 118 |
try:
|
| 119 |
conn.executescript(
|
| 120 |
"""
|
| 121 |
-
CREATE TABLE IF NOT EXISTS proxy_models (
|
| 122 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 123 |
-
model_id TEXT UNIQUE NOT NULL,
|
| 124 |
-
display_name TEXT NOT NULL,
|
| 125 |
-
provider TEXT NOT NULL DEFAULT 'nvidia-nim',
|
| 126 |
-
description TEXT,
|
| 127 |
-
enabled INTEGER NOT NULL DEFAULT 1,
|
| 128 |
-
featured INTEGER NOT NULL DEFAULT 0,
|
| 129 |
-
sort_order INTEGER NOT NULL DEFAULT 0,
|
| 130 |
-
request_count INTEGER NOT NULL DEFAULT 0,
|
| 131 |
-
success_count INTEGER NOT NULL DEFAULT 0,
|
| 132 |
-
failure_count INTEGER NOT NULL DEFAULT 0,
|
| 133 |
-
healthcheck_count INTEGER NOT NULL DEFAULT 0,
|
| 134 |
-
healthcheck_success_count INTEGER NOT NULL DEFAULT 0,
|
| 135 |
-
last_used_at TEXT,
|
| 136 |
-
last_healthcheck_at TEXT,
|
| 137 |
-
last_health_status INTEGER,
|
| 138 |
-
last_latency_ms REAL,
|
| 139 |
-
created_at TEXT NOT NULL,
|
| 140 |
-
updated_at TEXT NOT NULL
|
| 141 |
-
);
|
| 142 |
-
|
| 143 |
-
CREATE TABLE IF NOT EXISTS api_keys (
|
| 144 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 145 |
-
name TEXT UNIQUE NOT NULL,
|
| 146 |
-
api_key TEXT NOT NULL,
|
| 147 |
-
enabled INTEGER NOT NULL DEFAULT 1,
|
| 148 |
-
request_count INTEGER NOT NULL DEFAULT 0,
|
| 149 |
-
success_count INTEGER NOT NULL DEFAULT 0,
|
| 150 |
-
failure_count INTEGER NOT NULL DEFAULT 0,
|
| 151 |
-
healthcheck_count INTEGER NOT NULL DEFAULT 0,
|
| 152 |
-
healthcheck_success_count INTEGER NOT NULL DEFAULT 0,
|
| 153 |
-
last_used_at TEXT,
|
| 154 |
-
last_tested_at TEXT,
|
| 155 |
-
last_latency_ms REAL,
|
| 156 |
-
created_at TEXT NOT NULL,
|
| 157 |
-
updated_at TEXT NOT NULL
|
| 158 |
-
);
|
| 159 |
-
|
| 160 |
CREATE TABLE IF NOT EXISTS response_records (
|
| 161 |
-
|
| 162 |
-
|
| 163 |
parent_response_id TEXT,
|
| 164 |
-
model_id
|
| 165 |
-
api_key_id INTEGER,
|
| 166 |
request_json TEXT NOT NULL,
|
| 167 |
input_items_json TEXT NOT NULL,
|
| 168 |
output_json TEXT NOT NULL,
|
| 169 |
output_items_json TEXT NOT NULL,
|
| 170 |
status TEXT NOT NULL,
|
|
|
|
|
|
|
|
|
|
| 171 |
created_at TEXT NOT NULL
|
| 172 |
);
|
| 173 |
|
| 174 |
-
CREATE
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 184 |
);
|
| 185 |
|
| 186 |
-
CREATE TABLE IF NOT EXISTS
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
);
|
| 190 |
"""
|
| 191 |
)
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
) VALUES (?, ?, 'nvidia-nim', ?, 1, ?, ?, ?, ?)
|
| 200 |
-
""",
|
| 201 |
-
(model_id, display_name, description, featured, sort_order, now, now),
|
| 202 |
-
)
|
| 203 |
-
|
| 204 |
-
defaults = {
|
| 205 |
-
"healthcheck_enabled": "true",
|
| 206 |
-
"healthcheck_interval_minutes": str(DEFAULT_HEALTH_INTERVAL_MINUTES),
|
| 207 |
-
"healthcheck_prompt": DEFAULT_HEALTH_PROMPT,
|
| 208 |
-
"public_history_hours": str(PUBLIC_HISTORY_HOURS),
|
| 209 |
-
}
|
| 210 |
-
for key, value in defaults.items():
|
| 211 |
-
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (key, value))
|
| 212 |
-
|
| 213 |
-
if DEFAULT_ENV_KEY:
|
| 214 |
-
conn.execute(
|
| 215 |
-
"""
|
| 216 |
-
INSERT OR IGNORE INTO api_keys (name, api_key, enabled, created_at, updated_at)
|
| 217 |
-
VALUES ('env-default', ?, 1, ?, ?)
|
| 218 |
-
""",
|
| 219 |
-
(DEFAULT_ENV_KEY, now, now),
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
conn.commit()
|
| 223 |
finally:
|
| 224 |
conn.close()
|
| 225 |
|
| 226 |
|
| 227 |
-
def
|
| 228 |
-
|
| 229 |
-
return row["value"] if row else default
|
| 230 |
-
|
| 231 |
|
| 232 |
-
def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
|
| 233 |
-
conn.execute(
|
| 234 |
-
"""
|
| 235 |
-
INSERT INTO settings (key, value) VALUES (?, ?)
|
| 236 |
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
| 237 |
-
""",
|
| 238 |
-
(key, value),
|
| 239 |
-
)
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
-
def get_settings_payload(conn: sqlite3.Connection) -> dict[str, Any]:
|
| 243 |
-
return {
|
| 244 |
-
"healthcheck_enabled": bool_value(get_setting(conn, "healthcheck_enabled", "true")),
|
| 245 |
-
"healthcheck_interval_minutes": int(get_setting(conn, "healthcheck_interval_minutes", str(DEFAULT_HEALTH_INTERVAL_MINUTES))),
|
| 246 |
-
"healthcheck_prompt": get_setting(conn, "healthcheck_prompt", DEFAULT_HEALTH_PROMPT),
|
| 247 |
-
"public_history_hours": int(get_setting(conn, "public_history_hours", str(PUBLIC_HISTORY_HOURS))),
|
| 248 |
-
}
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
def mask_secret(secret: str) -> str:
|
| 252 |
-
if len(secret) <= 8:
|
| 253 |
-
return f"{secret[:2]}***"
|
| 254 |
-
return f"{secret[:4]}...{secret[-4:]}"
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
def create_admin_token() -> str:
|
| 258 |
-
serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="nim-admin-auth")
|
| 259 |
-
return serializer.dumps({"role": "admin"})
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
|
| 263 |
-
serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="nim-admin-auth")
|
| 264 |
try:
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
|
| 271 |
-
def
|
| 272 |
-
|
| 273 |
-
if
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
-
def
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
| 285 |
token: str | None = None
|
| 286 |
if authorization and authorization.startswith("Bearer "):
|
| 287 |
token = authorization.removeprefix("Bearer ").strip()
|
| 288 |
elif x_api_key:
|
| 289 |
token = x_api_key.strip()
|
|
|
|
|
|
|
| 290 |
if not token:
|
| 291 |
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="
|
| 292 |
-
|
| 293 |
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API 鉴权失败。")
|
| 294 |
-
return True
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
def fetch_model_by_identifier(conn: sqlite3.Connection, identifier: str | int, enabled_only: bool = False) -> sqlite3.Row | None:
|
| 298 |
-
clause = "AND enabled = 1" if enabled_only else ""
|
| 299 |
-
if isinstance(identifier, int) or (isinstance(identifier, str) and identifier.isdigit()):
|
| 300 |
-
row = conn.execute(f"SELECT * FROM proxy_models WHERE id = ? {clause}", (int(identifier),)).fetchone()
|
| 301 |
-
if row:
|
| 302 |
-
return row
|
| 303 |
-
return conn.execute(f"SELECT * FROM proxy_models WHERE model_id = ? {clause}", (str(identifier),)).fetchone()
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
def fetch_key_by_identifier(conn: sqlite3.Connection, identifier: str | int, enabled_only: bool = False) -> sqlite3.Row | None:
|
| 307 |
-
clause = "AND enabled = 1" if enabled_only else ""
|
| 308 |
-
if isinstance(identifier, int) or (isinstance(identifier, str) and str(identifier).isdigit()):
|
| 309 |
-
row = conn.execute(f"SELECT * FROM api_keys WHERE id = ? {clause}", (int(identifier),)).fetchone()
|
| 310 |
-
if row:
|
| 311 |
-
return row
|
| 312 |
-
return conn.execute(f"SELECT * FROM api_keys WHERE name = ? {clause}", (str(identifier),)).fetchone()
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
async def select_api_key(conn: sqlite3.Connection, explicit_id: int | None = None) -> sqlite3.Row:
|
| 316 |
-
if explicit_id is not None:
|
| 317 |
-
row = fetch_key_by_identifier(conn, explicit_id, enabled_only=True)
|
| 318 |
-
if row:
|
| 319 |
-
return row
|
| 320 |
-
|
| 321 |
-
key_rows = conn.execute(
|
| 322 |
-
"""
|
| 323 |
-
SELECT * FROM api_keys
|
| 324 |
-
WHERE enabled = 1
|
| 325 |
-
ORDER BY id ASC
|
| 326 |
-
"""
|
| 327 |
-
).fetchall()
|
| 328 |
-
if not key_rows:
|
| 329 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="??????? NVIDIA NIM Key?")
|
| 330 |
-
|
| 331 |
-
global api_key_rr_index
|
| 332 |
-
lock = await get_api_key_selection_lock()
|
| 333 |
-
async with lock:
|
| 334 |
-
selected = key_rows[api_key_rr_index % len(key_rows)]
|
| 335 |
-
api_key_rr_index = (api_key_rr_index + 1) % len(key_rows)
|
| 336 |
-
return selected
|
| 337 |
-
|
| 338 |
-
def row_to_model_item(row: sqlite3.Row) -> dict[str, Any]:
|
| 339 |
-
status_name = "unknown"
|
| 340 |
-
if row["last_health_status"] is not None:
|
| 341 |
-
status_name = "healthy" if bool(row["last_health_status"]) else "down"
|
| 342 |
-
return {
|
| 343 |
-
"id": row["id"],
|
| 344 |
-
"model_id": row["model_id"],
|
| 345 |
-
"name": row["model_id"],
|
| 346 |
-
"display_name": row["display_name"],
|
| 347 |
-
"endpoint": "/v1/responses",
|
| 348 |
-
"provider": row["provider"],
|
| 349 |
-
"description": row["description"],
|
| 350 |
-
"enabled": bool(row["enabled"]),
|
| 351 |
-
"featured": bool(row["featured"]),
|
| 352 |
-
"sort_order": row["sort_order"],
|
| 353 |
-
"status": status_name,
|
| 354 |
-
"request_count": row["request_count"],
|
| 355 |
-
"success_count": row["success_count"],
|
| 356 |
-
"failure_count": row["failure_count"],
|
| 357 |
-
"healthcheck_count": row["healthcheck_count"],
|
| 358 |
-
"healthcheck_success_count": row["healthcheck_success_count"],
|
| 359 |
-
"last_used_at": row["last_used_at"],
|
| 360 |
-
"last_healthcheck_at": row["last_healthcheck_at"],
|
| 361 |
-
"last_health_status": None if row["last_health_status"] is None else bool(row["last_health_status"]),
|
| 362 |
-
"last_latency_ms": row["last_latency_ms"],
|
| 363 |
-
"created_at": row["created_at"],
|
| 364 |
-
"updated_at": row["updated_at"],
|
| 365 |
-
}
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
def row_to_key_item(row: sqlite3.Row) -> dict[str, Any]:
|
| 369 |
-
total_checks = row["healthcheck_count"] or 0
|
| 370 |
-
ok_checks = row["healthcheck_success_count"] or 0
|
| 371 |
-
success_ratio = (ok_checks / total_checks) if total_checks else None
|
| 372 |
-
status_name = "healthy" if success_ratio and success_ratio >= 0.8 else "unknown"
|
| 373 |
-
return {
|
| 374 |
-
"id": row["id"],
|
| 375 |
-
"name": row["name"],
|
| 376 |
-
"label": row["name"],
|
| 377 |
-
"masked_key": mask_secret(row["api_key"]),
|
| 378 |
-
"enabled": bool(row["enabled"]),
|
| 379 |
-
"status": status_name,
|
| 380 |
-
"request_count": row["request_count"],
|
| 381 |
-
"success_count": row["success_count"],
|
| 382 |
-
"failure_count": row["failure_count"],
|
| 383 |
-
"healthcheck_count": row["healthcheck_count"],
|
| 384 |
-
"healthcheck_success_count": row["healthcheck_success_count"],
|
| 385 |
-
"last_used_at": row["last_used_at"],
|
| 386 |
-
"last_tested": row["last_tested_at"],
|
| 387 |
-
"last_tested_at": row["last_tested_at"],
|
| 388 |
-
"last_latency_ms": row["last_latency_ms"],
|
| 389 |
-
"created_at": row["created_at"],
|
| 390 |
-
"updated_at": row["updated_at"],
|
| 391 |
-
}
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
def make_error(status_code: int, message: str, error_type: str = "invalid_request_error") -> JSONResponse:
|
| 395 |
-
return JSONResponse(
|
| 396 |
-
status_code=status_code,
|
| 397 |
-
content={"error": {"message": message, "type": error_type, "code": status_code}},
|
| 398 |
-
)
|
| 399 |
|
| 400 |
def normalize_content(content: Any, role: str) -> list[dict[str, Any]]:
|
| 401 |
if content is None:
|
|
@@ -440,7 +328,6 @@ def normalize_input_items(value: Any) -> list[dict[str, Any]]:
|
|
| 440 |
if not isinstance(item, dict):
|
| 441 |
items.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": str(item)}]})
|
| 442 |
continue
|
| 443 |
-
|
| 444 |
item_type = item.get("type")
|
| 445 |
if item_type == "message" or item.get("role"):
|
| 446 |
role = item.get("role", "user")
|
|
@@ -456,14 +343,12 @@ def normalize_input_items(value: Any) -> list[dict[str, Any]]:
|
|
| 456 |
arguments = item.get("arguments", "{}")
|
| 457 |
if not isinstance(arguments, str):
|
| 458 |
arguments = json_dumps(arguments)
|
| 459 |
-
items.append(
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
}
|
| 466 |
-
)
|
| 467 |
continue
|
| 468 |
if item_type in {"input_text", "output_text", "text"}:
|
| 469 |
items.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": item.get("text", "")}]})
|
|
@@ -492,25 +377,6 @@ def extract_text_from_content(content: Any) -> str:
|
|
| 492 |
return str(content)
|
| 493 |
|
| 494 |
|
| 495 |
-
def load_previous_conversation_items(conn: sqlite3.Connection, previous_response_id: str | None) -> list[dict[str, Any]]:
|
| 496 |
-
if not previous_response_id:
|
| 497 |
-
return []
|
| 498 |
-
records: list[sqlite3.Row] = []
|
| 499 |
-
current = previous_response_id
|
| 500 |
-
while current:
|
| 501 |
-
row = conn.execute("SELECT * FROM response_records WHERE response_id = ?", (current,)).fetchone()
|
| 502 |
-
if not row:
|
| 503 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"previous_response_id '{current}' was not found.")
|
| 504 |
-
records.append(row)
|
| 505 |
-
current = row["parent_response_id"]
|
| 506 |
-
|
| 507 |
-
items: list[dict[str, Any]] = []
|
| 508 |
-
for row in reversed(records):
|
| 509 |
-
items.extend(json.loads(row["input_items_json"]))
|
| 510 |
-
items.extend(json.loads(row["output_items_json"]))
|
| 511 |
-
return items
|
| 512 |
-
|
| 513 |
-
|
| 514 |
def items_to_chat_messages(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 515 |
messages: list[dict[str, Any]] = []
|
| 516 |
pending_tool_calls: list[dict[str, Any]] = []
|
|
@@ -656,7 +522,11 @@ def extract_text_and_tool_calls(message: dict[str, Any]) -> tuple[str, list[dict
|
|
| 656 |
arguments = part.get("arguments") or "{}"
|
| 657 |
if not isinstance(arguments, str):
|
| 658 |
arguments = json_dumps(arguments)
|
| 659 |
-
tool_calls.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
|
| 661 |
for tool_call in message.get("tool_calls") or []:
|
| 662 |
if not isinstance(tool_call, dict):
|
|
@@ -665,7 +535,13 @@ def extract_text_and_tool_calls(message: dict[str, Any]) -> tuple[str, list[dict
|
|
| 665 |
arguments = function_data.get("arguments") or tool_call.get("arguments") or "{}"
|
| 666 |
if not isinstance(arguments, str):
|
| 667 |
arguments = json_dumps(arguments)
|
| 668 |
-
tool_calls.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
|
| 670 |
deduped: list[dict[str, Any]] = []
|
| 671 |
seen_ids: set[str] = set()
|
|
@@ -676,7 +552,6 @@ def extract_text_and_tool_calls(message: dict[str, Any]) -> tuple[str, list[dict
|
|
| 676 |
deduped.append(tool_call)
|
| 677 |
return "\n".join(filter(None, text_chunks)).strip(), deduped
|
| 678 |
|
| 679 |
-
|
| 680 |
def build_choice_alias(output_items: list[dict[str, Any]], finish_reason: str | None) -> list[dict[str, Any]]:
|
| 681 |
content_parts: list[dict[str, Any]] = []
|
| 682 |
for item in output_items:
|
|
@@ -699,9 +574,22 @@ def chat_completion_to_response(body: dict[str, Any], upstream_json: dict[str, A
|
|
| 699 |
response_id = upstream_json.get("id") or f"resp_{uuid.uuid4().hex}"
|
| 700 |
output_items: list[dict[str, Any]] = []
|
| 701 |
if assistant_text:
|
| 702 |
-
output_items.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
for tool_call in tool_calls:
|
| 704 |
-
output_items.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
usage = upstream_json.get("usage") or {}
|
| 706 |
return {
|
| 707 |
"id": response_id,
|
|
@@ -715,680 +603,366 @@ def chat_completion_to_response(body: dict[str, Any], upstream_json: dict[str, A
|
|
| 715 |
"previous_response_id": previous_response_id,
|
| 716 |
"store": True,
|
| 717 |
"text": body.get("text") or {"format": {"type": "text"}},
|
| 718 |
-
"usage": {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
"choices": build_choice_alias(output_items, finish_reason),
|
| 720 |
-
"upstream": {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
}
|
| 722 |
|
| 723 |
-
def store_response_record(conn: sqlite3.Connection, response_payload: dict[str, Any], request_body: dict[str, Any], input_items: list[dict[str, Any]], model_row: sqlite3.Row, api_key_row: sqlite3.Row) -> None:
|
| 724 |
-
conn.execute(
|
| 725 |
-
"""
|
| 726 |
-
INSERT OR REPLACE INTO response_records (
|
| 727 |
-
response_id, parent_response_id, model_id, api_key_id, request_json,
|
| 728 |
-
input_items_json, output_json, output_items_json, status, created_at
|
| 729 |
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 730 |
-
""",
|
| 731 |
-
(
|
| 732 |
-
response_payload["id"],
|
| 733 |
-
request_body.get("previous_response_id"),
|
| 734 |
-
model_row["id"],
|
| 735 |
-
api_key_row["id"],
|
| 736 |
-
json_dumps(request_body),
|
| 737 |
-
json_dumps(input_items),
|
| 738 |
-
json_dumps(response_payload),
|
| 739 |
-
json_dumps(response_payload.get("output") or []),
|
| 740 |
-
response_payload.get("status", "completed"),
|
| 741 |
-
utcnow_iso(),
|
| 742 |
-
),
|
| 743 |
-
)
|
| 744 |
-
|
| 745 |
|
| 746 |
-
def
|
| 747 |
-
|
| 748 |
-
|
|
|
|
|
|
|
| 749 |
conn.execute(
|
| 750 |
"""
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
last_latency_ms = ?,
|
| 757 |
-
updated_at = ?
|
| 758 |
-
WHERE id = ?
|
| 759 |
""",
|
| 760 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
)
|
| 762 |
conn.execute(
|
| 763 |
"""
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
updated_at = ?
|
| 770 |
-
WHERE id =
|
| 771 |
""",
|
| 772 |
-
(
|
| 773 |
)
|
| 774 |
-
return
|
| 775 |
-
conn.execute(
|
| 776 |
-
"""
|
| 777 |
-
UPDATE proxy_models
|
| 778 |
-
SET request_count = request_count + 1,
|
| 779 |
-
success_count = success_count + ?,
|
| 780 |
-
failure_count = failure_count + ?,
|
| 781 |
-
last_used_at = ?,
|
| 782 |
-
last_latency_ms = ?,
|
| 783 |
-
updated_at = ?
|
| 784 |
-
WHERE id = ?
|
| 785 |
-
""",
|
| 786 |
-
(1 if ok else 0, 0 if ok else 1, now, latency_ms, now, model_row["id"]),
|
| 787 |
-
)
|
| 788 |
-
conn.execute(
|
| 789 |
-
"""
|
| 790 |
-
UPDATE api_keys
|
| 791 |
-
SET request_count = request_count + 1,
|
| 792 |
-
success_count = success_count + ?,
|
| 793 |
-
failure_count = failure_count + ?,
|
| 794 |
-
last_used_at = ?,
|
| 795 |
-
last_latency_ms = ?,
|
| 796 |
-
updated_at = ?
|
| 797 |
-
WHERE id = ?
|
| 798 |
-
""",
|
| 799 |
-
(1 if ok else 0, 0 if ok else 1, now, latency_ms, now, api_key_row["id"]),
|
| 800 |
-
)
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
def insert_health_record(conn: sqlite3.Connection, model_row: sqlite3.Row, api_key_row: sqlite3.Row, *, ok: bool, status_code: int | None, latency_ms: float | None, error_message: str | None, response_excerpt: str | None) -> None:
|
| 804 |
-
conn.execute(
|
| 805 |
-
"""
|
| 806 |
-
INSERT INTO health_check_records (
|
| 807 |
-
model_id, api_key_id, ok, status_code, latency_ms, error_message, response_excerpt, checked_at
|
| 808 |
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 809 |
-
""",
|
| 810 |
-
(model_row["id"], api_key_row["id"], 1 if ok else 0, status_code, latency_ms, error_message, response_excerpt, utcnow_iso()),
|
| 811 |
-
)
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
async def post_nvidia_chat_completion(api_key: str, payload: dict[str, Any]) -> tuple[dict[str, Any], float]:
|
| 815 |
-
started = time.perf_counter()
|
| 816 |
-
client = await get_http_client()
|
| 817 |
-
response = await client.post(
|
| 818 |
-
CHAT_COMPLETIONS_URL,
|
| 819 |
-
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
| 820 |
-
json=payload,
|
| 821 |
-
)
|
| 822 |
-
latency_ms = round((time.perf_counter() - started) * 1000, 2)
|
| 823 |
-
if response.status_code >= 400:
|
| 824 |
-
try:
|
| 825 |
-
error_payload = response.json()
|
| 826 |
-
detail = error_payload.get("error", {}).get("message") or json_dumps(error_payload)
|
| 827 |
-
except Exception:
|
| 828 |
-
detail = response.text
|
| 829 |
-
raise HTTPException(status_code=response.status_code, detail=f"NVIDIA NIM 请求失败:{detail}")
|
| 830 |
-
return response.json(), latency_ms
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
async def perform_healthcheck(conn: sqlite3.Connection, model_row: sqlite3.Row, api_key_row: sqlite3.Row, prompt: str) -> dict[str, Any]:
|
| 834 |
-
payload = {"model": model_row["model_id"], "messages": [{"role": "user", "content": prompt}], "max_tokens": 32, "temperature": 0}
|
| 835 |
-
try:
|
| 836 |
-
upstream_json, latency_ms = await post_nvidia_chat_completion(api_key_row["api_key"], payload)
|
| 837 |
-
message, _finish_reason = extract_upstream_message(upstream_json)
|
| 838 |
-
assistant_text, _tool_calls = extract_text_and_tool_calls(message)
|
| 839 |
-
ok = True
|
| 840 |
-
detail = assistant_text or "模型响应正常。"
|
| 841 |
-
status_code = 200
|
| 842 |
-
error_message = None
|
| 843 |
-
response_excerpt = detail[:200]
|
| 844 |
-
except HTTPException as exc:
|
| 845 |
-
ok = False
|
| 846 |
-
latency_ms = None
|
| 847 |
-
detail = exc.detail
|
| 848 |
-
status_code = exc.status_code
|
| 849 |
-
error_message = exc.detail
|
| 850 |
-
response_excerpt = None
|
| 851 |
-
update_usage_stats(conn, model_row, api_key_row, ok=ok, latency_ms=latency_ms, is_healthcheck=True)
|
| 852 |
-
insert_health_record(conn, model_row, api_key_row, ok=ok, status_code=status_code, latency_ms=latency_ms, error_message=error_message, response_excerpt=response_excerpt)
|
| 853 |
-
conn.commit()
|
| 854 |
-
return {"model": model_row["model_id"], "display_name": model_row["display_name"], "api_key": api_key_row["name"], "status": "healthy" if ok else "down", "ok": ok, "latency": latency_ms, "status_code": status_code, "detail": detail, "checked_at": utcnow_iso()}
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
async def run_healthchecks(model_identifier: str | int | None = None, api_key_identifier: str | int | None = None, prompt: str | None = None) -> list[dict[str, Any]]:
|
| 858 |
-
conn = get_db_connection()
|
| 859 |
-
try:
|
| 860 |
-
settings_payload = get_settings_payload(conn)
|
| 861 |
-
effective_prompt = prompt or settings_payload["healthcheck_prompt"]
|
| 862 |
-
if api_key_identifier is not None:
|
| 863 |
-
api_key_row = fetch_key_by_identifier(conn, api_key_identifier, enabled_only=True)
|
| 864 |
-
if not api_key_row:
|
| 865 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到 API Key。")
|
| 866 |
-
key_rows = [api_key_row]
|
| 867 |
-
else:
|
| 868 |
-
key_rows = conn.execute("SELECT * FROM api_keys WHERE enabled = 1 ORDER BY id ASC").fetchall()
|
| 869 |
-
if not key_rows:
|
| 870 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="??????? NVIDIA NIM Key?")
|
| 871 |
-
if model_identifier is not None:
|
| 872 |
-
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 873 |
-
if not model_row:
|
| 874 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
|
| 875 |
-
model_rows = [model_row]
|
| 876 |
-
else:
|
| 877 |
-
model_rows = conn.execute("SELECT * FROM proxy_models WHERE enabled = 1 ORDER BY sort_order ASC, model_id ASC").fetchall()
|
| 878 |
-
results: list[dict[str, Any]] = []
|
| 879 |
-
for index, model_row in enumerate(model_rows):
|
| 880 |
-
api_key_row = key_rows[index % len(key_rows)]
|
| 881 |
-
results.append(await perform_healthcheck(conn, model_row, api_key_row, effective_prompt))
|
| 882 |
-
return results
|
| 883 |
-
finally:
|
| 884 |
-
conn.close()
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
def build_public_health_payload(hours: int | None = None) -> dict[str, Any]:
|
| 888 |
-
conn = get_db_connection()
|
| 889 |
-
try:
|
| 890 |
-
settings_payload = get_settings_payload(conn)
|
| 891 |
-
effective_hours = hours or settings_payload["public_history_hours"]
|
| 892 |
-
since = utcnow() - timedelta(hours=effective_hours)
|
| 893 |
-
models = conn.execute("SELECT * FROM proxy_models WHERE enabled = 1 ORDER BY sort_order ASC, model_id ASC").fetchall()
|
| 894 |
-
result_models: list[dict[str, Any]] = []
|
| 895 |
-
last_updated: str | None = None
|
| 896 |
-
for model in models:
|
| 897 |
-
rows = conn.execute("SELECT * FROM health_check_records WHERE model_id = ? AND checked_at >= ? ORDER BY checked_at ASC", (model["id"], since.isoformat())).fetchall()
|
| 898 |
-
hourly = []
|
| 899 |
-
ok_count = 0
|
| 900 |
-
for row in rows:
|
| 901 |
-
status_name = "healthy" if row["ok"] else "down"
|
| 902 |
-
hourly.append({"time": row["checked_at"], "status": status_name, "latency": row["latency_ms"]})
|
| 903 |
-
ok_count += 1 if row["ok"] else 0
|
| 904 |
-
last_updated = row["checked_at"]
|
| 905 |
-
total = len(rows)
|
| 906 |
-
success_rate = round((ok_count / total) * 100, 1) if total else 0.0
|
| 907 |
-
model_status = "unknown" if model["last_health_status"] is None else ("healthy" if model["last_health_status"] else "down")
|
| 908 |
-
result_models.append({"id": model["id"], "model_id": model["model_id"], "name": model["display_name"], "display_name": model["display_name"], "endpoint": "/v1/responses", "status": model_status, "beat": f"{success_rate}%", "hourly": hourly, "last_health_status": None if model["last_health_status"] is None else bool(model["last_health_status"]), "last_healthcheck_at": model["last_healthcheck_at"], "success_rate": success_rate, "points": [{"hour": entry["time"], "label": parse_datetime(entry["time"]).strftime("%H:%M") if parse_datetime(entry["time"]) else entry["time"], "ok": entry["status"] == "healthy", "latency_ms": entry["latency"]} for entry in hourly]})
|
| 909 |
-
return {"generated_at": utcnow_iso(), "last_updated": last_updated, "hours": effective_hours, "models": result_models}
|
| 910 |
-
finally:
|
| 911 |
-
conn.close()
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
def schedule_healthchecks() -> None:
|
| 915 |
-
conn = get_db_connection()
|
| 916 |
-
try:
|
| 917 |
-
settings_payload = get_settings_payload(conn)
|
| 918 |
-
finally:
|
| 919 |
-
conn.close()
|
| 920 |
-
interval = max(5, int(settings_payload["healthcheck_interval_minutes"]))
|
| 921 |
-
enabled = bool(settings_payload["healthcheck_enabled"])
|
| 922 |
-
if scheduler.get_job("nim-hourly-healthcheck"):
|
| 923 |
-
scheduler.remove_job("nim-hourly-healthcheck")
|
| 924 |
-
if enabled:
|
| 925 |
-
scheduler.add_job(run_healthchecks, "interval", minutes=interval, id="nim-hourly-healthcheck", replace_existing=True, next_run_time=utcnow() + timedelta(seconds=10))
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
init_db()
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
@asynccontextmanager
|
| 932 |
-
async def lifespan(_app: FastAPI):
|
| 933 |
-
global http_client, api_key_selection_lock, api_key_rr_index
|
| 934 |
-
init_db()
|
| 935 |
-
api_key_selection_lock = asyncio.Lock()
|
| 936 |
-
api_key_rr_index = 0
|
| 937 |
-
http_client = await get_http_client()
|
| 938 |
-
if not scheduler.running:
|
| 939 |
-
scheduler.start()
|
| 940 |
-
schedule_healthchecks()
|
| 941 |
-
try:
|
| 942 |
-
yield
|
| 943 |
-
finally:
|
| 944 |
-
if scheduler.running:
|
| 945 |
-
scheduler.shutdown(wait=False)
|
| 946 |
-
if http_client is not None and not http_client.is_closed:
|
| 947 |
-
await http_client.aclose()
|
| 948 |
-
http_client = None
|
| 949 |
-
api_key_selection_lock = None
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
app = FastAPI(title="NIM 响应网关", lifespan=lifespan)
|
| 953 |
-
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
def render_html(filename: str) -> HTMLResponse:
|
| 957 |
-
content = (STATIC_DIR / filename).read_text(encoding="utf-8")
|
| 958 |
-
return HTMLResponse(content=content, media_type="text/html; charset=utf-8")
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
@app.get("/")
|
| 962 |
-
async def public_dashboard() -> HTMLResponse:
|
| 963 |
-
return render_html("index.html")
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
@app.get("/admin")
|
| 967 |
-
async def admin_dashboard() -> HTMLResponse:
|
| 968 |
-
return render_html("admin.html")
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
@app.get("/api/health/public")
|
| 972 |
-
async def public_health(hours: int | None = None) -> dict[str, Any]:
|
| 973 |
-
return build_public_health_payload(hours)
|
| 974 |
-
|
| 975 |
-
@app.get("/v1/models")
|
| 976 |
-
async def list_models() -> dict[str, Any]:
|
| 977 |
-
conn = get_db_connection()
|
| 978 |
-
try:
|
| 979 |
-
rows = conn.execute("SELECT * FROM proxy_models WHERE enabled = 1 ORDER BY sort_order ASC, model_id ASC").fetchall()
|
| 980 |
-
data = [{"id": row["model_id"], "object": "model", "created": 0, "owned_by": "nvidia-nim", "display_name": row["display_name"], "status": ("unknown" if row["last_health_status"] is None else ("healthy" if row["last_health_status"] else "down"))} for row in rows]
|
| 981 |
-
return {"object": "list", "data": data, "models": data}
|
| 982 |
-
finally:
|
| 983 |
-
conn.close()
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
@app.get("/v1/responses/{response_id}")
|
| 987 |
-
async def get_response(response_id: str, _: bool = Depends(require_proxy_token_if_configured)):
|
| 988 |
-
conn = get_db_connection()
|
| 989 |
-
try:
|
| 990 |
-
row = conn.execute("SELECT output_json FROM response_records WHERE response_id = ?", (response_id,)).fetchone()
|
| 991 |
-
if not row:
|
| 992 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Response not found.")
|
| 993 |
-
return json.loads(row["output_json"])
|
| 994 |
-
finally:
|
| 995 |
-
conn.close()
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
@app.post("/v1/responses")
|
| 999 |
-
async def create_response(request: Request, _: bool = Depends(require_proxy_token_if_configured)):
|
| 1000 |
-
body = await request.json()
|
| 1001 |
-
if not isinstance(body, dict):
|
| 1002 |
-
return make_error(status.HTTP_400_BAD_REQUEST, "?????? JSON ???")
|
| 1003 |
-
if not body.get("model"):
|
| 1004 |
-
return make_error(status.HTTP_400_BAD_REQUEST, "?? model ???")
|
| 1005 |
-
if body.get("input") is None:
|
| 1006 |
-
return make_error(status.HTTP_400_BAD_REQUEST, "?? input ???")
|
| 1007 |
-
|
| 1008 |
-
conn = get_db_connection()
|
| 1009 |
-
try:
|
| 1010 |
-
model_row = fetch_model_by_identifier(conn, body["model"], enabled_only=True)
|
| 1011 |
-
if not model_row:
|
| 1012 |
-
return make_error(status.HTTP_404_NOT_FOUND, f"?? {body['model']} ????????")
|
| 1013 |
-
api_key_row = await select_api_key(conn)
|
| 1014 |
-
previous_items = load_previous_conversation_items(conn, body.get("previous_response_id"))
|
| 1015 |
-
input_items = normalize_input_items(body.get("input"))
|
| 1016 |
-
merged_items = previous_items + input_items
|
| 1017 |
-
chat_payload = build_chat_payload(body, merged_items)
|
| 1018 |
-
try:
|
| 1019 |
-
upstream_json, latency_ms = await post_nvidia_chat_completion(api_key_row["api_key"], chat_payload)
|
| 1020 |
-
except HTTPException as exc:
|
| 1021 |
-
update_usage_stats(conn, model_row, api_key_row, ok=False, latency_ms=None, is_healthcheck=False)
|
| 1022 |
-
conn.commit()
|
| 1023 |
-
raise exc
|
| 1024 |
-
response_payload = chat_completion_to_response(body, upstream_json, body.get("previous_response_id"))
|
| 1025 |
-
update_usage_stats(conn, model_row, api_key_row, ok=True, latency_ms=latency_ms, is_healthcheck=False)
|
| 1026 |
-
store_response_record(conn, response_payload, body, input_items, model_row, api_key_row)
|
| 1027 |
conn.commit()
|
| 1028 |
-
|
| 1029 |
-
if body.get("stream"):
|
| 1030 |
-
async def event_stream() -> Any:
|
| 1031 |
-
yield f"event: response.created\ndata: {json_dumps({'type': 'response.created', 'response': {'id': response_payload['id'], 'model': response_payload['model'], 'status': 'in_progress'}})}\n\n"
|
| 1032 |
-
for index, item in enumerate(response_payload.get("output") or []):
|
| 1033 |
-
yield f"event: response.output_item.added\ndata: {json_dumps({'type': 'response.output_item.added', 'output_index': index, 'item': item})}\n\n"
|
| 1034 |
-
if item.get("type") == "message":
|
| 1035 |
-
text_value = extract_text_from_content(item.get("content"))
|
| 1036 |
-
if text_value:
|
| 1037 |
-
yield f"event: response.output_text.delta\ndata: {json_dumps({'type': 'response.output_text.delta', 'output_index': index, 'delta': text_value})}\n\n"
|
| 1038 |
-
yield f"event: response.output_text.done\ndata: {json_dumps({'type': 'response.output_text.done', 'output_index': index, 'text': text_value})}\n\n"
|
| 1039 |
-
if item.get("type") == "function_call":
|
| 1040 |
-
yield f"event: response.function_call_arguments.done\ndata: {json_dumps({'type': 'response.function_call_arguments.done', 'output_index': index, 'arguments': item.get('arguments', '{}'), 'call_id': item.get('call_id')})}\n\n"
|
| 1041 |
-
yield f"event: response.output_item.done\ndata: {json_dumps({'type': 'response.output_item.done', 'output_index': index, 'item': item})}\n\n"
|
| 1042 |
-
yield f"event: response.completed\ndata: {json_dumps({'type': 'response.completed', 'response': response_payload})}\n\n"
|
| 1043 |
-
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 1044 |
-
return response_payload
|
| 1045 |
-
finally:
|
| 1046 |
-
conn.close()
|
| 1047 |
-
|
| 1048 |
-
@app.post("/admin/api/login")
|
| 1049 |
-
async def admin_login(request: Request, response: Response):
|
| 1050 |
-
if not ADMIN_PASSWORD:
|
| 1051 |
-
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置 PASSWORD 环境变量。")
|
| 1052 |
-
body = await request.json()
|
| 1053 |
-
password = body.get("password") if isinstance(body, dict) else None
|
| 1054 |
-
if password != ADMIN_PASSWORD:
|
| 1055 |
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误。")
|
| 1056 |
-
token = create_admin_token()
|
| 1057 |
-
response.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, max_age=60 * 60 * 24 * 7)
|
| 1058 |
-
return {"token": token, "access_token": token, "token_type": "bearer"}
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
@app.post("/admin/api/logout")
|
| 1062 |
-
async def admin_logout(response: Response, _: bool = Depends(require_admin)):
|
| 1063 |
-
response.delete_cookie(COOKIE_NAME)
|
| 1064 |
-
return {"message": "已退出登录。"}
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
@app.get("/admin/api/session")
|
| 1068 |
-
async def admin_session(_: bool = Depends(require_admin)):
|
| 1069 |
-
return {"ok": True}
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
@app.get("/admin/api/overview")
|
| 1073 |
-
async def admin_overview(_: bool = Depends(require_admin)):
|
| 1074 |
-
conn = get_db_connection()
|
| 1075 |
-
try:
|
| 1076 |
-
total_models = conn.execute("SELECT COUNT(*) AS count FROM proxy_models").fetchone()["count"]
|
| 1077 |
-
enabled_models = conn.execute("SELECT COUNT(*) AS count FROM proxy_models WHERE enabled = 1").fetchone()["count"]
|
| 1078 |
-
total_keys = conn.execute("SELECT COUNT(*) AS count FROM api_keys").fetchone()["count"]
|
| 1079 |
-
enabled_keys = conn.execute("SELECT COUNT(*) AS count FROM api_keys WHERE enabled = 1").fetchone()["count"]
|
| 1080 |
-
usage = conn.execute("SELECT COALESCE(SUM(request_count), 0) AS total_requests, COALESCE(SUM(success_count), 0) AS total_success, COALESCE(SUM(failure_count), 0) AS total_failures FROM proxy_models").fetchone()
|
| 1081 |
-
recent_rows = conn.execute("SELECT h.checked_at, h.ok, h.latency_ms, m.model_id FROM health_check_records h JOIN proxy_models m ON m.id = h.model_id ORDER BY h.checked_at DESC LIMIT 8").fetchall()
|
| 1082 |
-
return {
|
| 1083 |
-
"metrics": [
|
| 1084 |
-
{"label": "Enabled Models", "value": enabled_models},
|
| 1085 |
-
{"label": "Enabled Keys", "value": enabled_keys},
|
| 1086 |
-
{"label": "Proxy Requests", "value": usage["total_requests"]},
|
| 1087 |
-
{"label": "Failures", "value": usage["total_failures"]},
|
| 1088 |
-
],
|
| 1089 |
-
"recent_checks": [{"time": row["checked_at"], "model": row["model_id"], "status": "healthy" if row["ok"] else "down", "latency": row["latency_ms"]} for row in recent_rows],
|
| 1090 |
-
"totals": {
|
| 1091 |
-
"total_models": total_models,
|
| 1092 |
-
"enabled_models": enabled_models,
|
| 1093 |
-
"total_keys": total_keys,
|
| 1094 |
-
"enabled_keys": enabled_keys,
|
| 1095 |
-
"total_requests": usage["total_requests"],
|
| 1096 |
-
"total_success": usage["total_success"],
|
| 1097 |
-
"total_failures": usage["total_failures"],
|
| 1098 |
-
},
|
| 1099 |
-
}
|
| 1100 |
-
finally:
|
| 1101 |
-
conn.close()
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
@app.get("/admin/api/models")
|
| 1105 |
-
async def admin_models(_: bool = Depends(require_admin)):
|
| 1106 |
-
conn = get_db_connection()
|
| 1107 |
-
try:
|
| 1108 |
-
rows = conn.execute("SELECT * FROM proxy_models ORDER BY sort_order ASC, model_id ASC").fetchall()
|
| 1109 |
-
return {"items": [row_to_model_item(row) for row in rows]}
|
| 1110 |
-
finally:
|
| 1111 |
-
conn.close()
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
@app.get("/admin/api/models/usage")
|
| 1115 |
-
async def admin_models_usage(_: bool = Depends(require_admin)):
|
| 1116 |
-
conn = get_db_connection()
|
| 1117 |
-
try:
|
| 1118 |
-
rows = conn.execute("SELECT * FROM proxy_models ORDER BY request_count DESC, model_id ASC").fetchall()
|
| 1119 |
-
return {"items": [row_to_model_item(row) for row in rows]}
|
| 1120 |
finally:
|
| 1121 |
conn.close()
|
| 1122 |
|
| 1123 |
|
| 1124 |
-
|
| 1125 |
-
async def admin_add_model(request: Request, _: bool = Depends(require_admin)):
|
| 1126 |
-
body = await request.json()
|
| 1127 |
-
model_id = (body.get("model_id") or body.get("name") or "").strip()
|
| 1128 |
-
display_name = (body.get("display_name") or model_id).strip()
|
| 1129 |
-
if not model_id:
|
| 1130 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="model_id is required.")
|
| 1131 |
conn = get_db_connection()
|
| 1132 |
try:
|
| 1133 |
now = utcnow_iso()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1134 |
conn.execute(
|
| 1135 |
"""
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
description = excluded.description,
|
| 1141 |
-
enabled = excluded.enabled,
|
| 1142 |
-
featured = excluded.featured,
|
| 1143 |
-
sort_order = excluded.sort_order,
|
| 1144 |
-
updated_at = excluded.updated_at
|
| 1145 |
""",
|
| 1146 |
-
(
|
| 1147 |
)
|
| 1148 |
conn.commit()
|
| 1149 |
-
row = fetch_model_by_identifier(conn, model_id)
|
| 1150 |
-
return {"item": row_to_model_item(row)}
|
| 1151 |
finally:
|
| 1152 |
conn.close()
|
| 1153 |
|
| 1154 |
|
| 1155 |
-
def
|
|
|
|
|
|
|
| 1156 |
conn = get_db_connection()
|
| 1157 |
try:
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1164 |
finally:
|
| 1165 |
conn.close()
|
| 1166 |
|
| 1167 |
|
| 1168 |
-
|
| 1169 |
-
async def admin_delete_model(model_identifier: str, _: bool = Depends(require_admin)):
|
| 1170 |
-
return delete_model_internal(model_identifier)
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
@app.post("/admin/api/models/remove")
|
| 1174 |
-
async def admin_remove_model_alias(request: Request, _: bool = Depends(require_admin)):
|
| 1175 |
-
body = await request.json()
|
| 1176 |
-
value = body.get("value") if isinstance(body, dict) else None
|
| 1177 |
-
if not value:
|
| 1178 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="value is required.")
|
| 1179 |
-
return delete_model_internal(str(value))
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
async def test_model_internal(model_identifier: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 1183 |
conn = get_db_connection()
|
| 1184 |
try:
|
| 1185 |
-
row =
|
|
|
|
|
|
|
|
|
|
| 1186 |
if not row:
|
| 1187 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到
|
| 1188 |
-
|
| 1189 |
-
return await perform_healthcheck(conn, row, api_key_row, (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT)
|
| 1190 |
-
finally:
|
| 1191 |
-
conn.close()
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
@app.post("/admin/api/models/test")
|
| 1195 |
-
async def admin_test_model_alias(request: Request, _: bool = Depends(require_admin)):
|
| 1196 |
-
body = await request.json()
|
| 1197 |
-
identifier = body.get("value") or body.get("model_id")
|
| 1198 |
-
if not identifier:
|
| 1199 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="value is required.")
|
| 1200 |
-
return await test_model_internal(str(identifier), body)
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
@app.post("/admin/api/models/{model_identifier}/test")
|
| 1204 |
-
async def admin_test_model(model_identifier: str, request: Request, _: bool = Depends(require_admin)):
|
| 1205 |
-
body = await request.json() if request.method == "POST" else {}
|
| 1206 |
-
return await test_model_internal(model_identifier, body)
|
| 1207 |
-
|
| 1208 |
-
@app.get("/admin/api/keys")
|
| 1209 |
-
async def admin_keys(_: bool = Depends(require_admin)):
|
| 1210 |
-
conn = get_db_connection()
|
| 1211 |
-
try:
|
| 1212 |
-
rows = conn.execute("SELECT * FROM api_keys ORDER BY id ASC").fetchall()
|
| 1213 |
-
return {"items": [row_to_key_item(row) for row in rows]}
|
| 1214 |
-
finally:
|
| 1215 |
-
conn.close()
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
@app.get("/admin/api/keys/usage")
|
| 1219 |
-
async def admin_keys_usage(_: bool = Depends(require_admin)):
|
| 1220 |
-
conn = get_db_connection()
|
| 1221 |
-
try:
|
| 1222 |
-
rows = conn.execute("SELECT * FROM api_keys ORDER BY request_count DESC, id ASC").fetchall()
|
| 1223 |
-
return {"items": [row_to_key_item(row) for row in rows]}
|
| 1224 |
finally:
|
| 1225 |
conn.close()
|
| 1226 |
|
| 1227 |
|
| 1228 |
-
|
| 1229 |
-
async def admin_add_key(request: Request, _: bool = Depends(require_admin)):
|
| 1230 |
-
body = await request.json()
|
| 1231 |
-
name = (body.get("name") or body.get("label") or "").strip()
|
| 1232 |
-
api_key = (body.get("api_key") or body.get("key") or "").strip()
|
| 1233 |
-
if not name or not api_key:
|
| 1234 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Both name and api_key are required.")
|
| 1235 |
conn = get_db_connection()
|
| 1236 |
try:
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
"""
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
finally:
|
| 1250 |
conn.close()
|
| 1251 |
|
| 1252 |
|
| 1253 |
-
def
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
|
| 1265 |
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1269 |
|
| 1270 |
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
value = body.get("value") if isinstance(body, dict) else None
|
| 1275 |
-
if not value:
|
| 1276 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="value is required.")
|
| 1277 |
-
return delete_key_internal(str(value))
|
| 1278 |
|
| 1279 |
|
| 1280 |
-
|
| 1281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
try:
|
| 1283 |
-
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 1288 |
-
if not model_row:
|
| 1289 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="??????")
|
| 1290 |
-
return await perform_healthcheck(conn, model_row, key_row, (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT)
|
| 1291 |
-
finally:
|
| 1292 |
-
conn.close()
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
async def test_all_keys_internal(payload: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
| 1296 |
-
conn = get_db_connection()
|
| 1297 |
try:
|
| 1298 |
-
|
| 1299 |
-
if not key_rows:
|
| 1300 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="??????? API Key?")
|
| 1301 |
-
model_identifier = (payload or {}).get("model_id") or DEFAULT_MODELS[0][0]
|
| 1302 |
-
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 1303 |
-
if not model_row:
|
| 1304 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="??????")
|
| 1305 |
-
prompt = (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT
|
| 1306 |
-
results: list[dict[str, Any]] = []
|
| 1307 |
-
for key_row in key_rows:
|
| 1308 |
-
results.append(await perform_healthcheck(conn, model_row, key_row, prompt))
|
| 1309 |
-
return results
|
| 1310 |
finally:
|
| 1311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1312 |
|
| 1313 |
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
identifier = body.get("value") or body.get("name") or body.get("label")
|
| 1318 |
-
if not identifier:
|
| 1319 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="value is required.")
|
| 1320 |
-
return await test_key_internal(str(identifier), body)
|
| 1321 |
|
| 1322 |
|
| 1323 |
-
@app.
|
| 1324 |
-
async def
|
| 1325 |
-
|
| 1326 |
-
results = await test_all_keys_internal(body)
|
| 1327 |
-
return {"items": results, "results": results}
|
| 1328 |
|
| 1329 |
|
| 1330 |
-
@app.
|
| 1331 |
-
async def
|
| 1332 |
-
|
| 1333 |
-
return await test_key_internal(key_identifier, body)
|
| 1334 |
|
| 1335 |
|
| 1336 |
-
@app.get("/
|
| 1337 |
-
async def
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
FROM health_check_records h
|
| 1345 |
-
JOIN proxy_models m ON m.id = h.model_id
|
| 1346 |
-
LEFT JOIN api_keys k ON k.id = h.api_key_id
|
| 1347 |
-
WHERE h.checked_at >= ?
|
| 1348 |
-
ORDER BY h.checked_at DESC
|
| 1349 |
-
LIMIT 200
|
| 1350 |
-
""",
|
| 1351 |
-
(since.isoformat(),),
|
| 1352 |
-
).fetchall()
|
| 1353 |
-
items = [{"id": row["id"], "model": row["display_name"], "model_id": row["model_id"], "api_key": row["key_name"], "status": "healthy" if row["ok"] else "down", "detail": row["response_excerpt"] or row["error_message"] or "暂无详情。", "latency": row["latency_ms"], "status_code": row["status_code"], "checked_at": row["checked_at"]} for row in rows]
|
| 1354 |
-
return {"items": items}
|
| 1355 |
-
finally:
|
| 1356 |
-
conn.close()
|
| 1357 |
|
| 1358 |
|
| 1359 |
-
@app.
|
| 1360 |
-
async def
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
return {"
|
| 1364 |
|
| 1365 |
|
| 1366 |
-
@app.get("/
|
| 1367 |
-
async def
|
| 1368 |
-
|
| 1369 |
-
try:
|
| 1370 |
-
return get_settings_payload(conn)
|
| 1371 |
-
finally:
|
| 1372 |
-
conn.close()
|
| 1373 |
|
| 1374 |
|
| 1375 |
-
@app.
|
| 1376 |
-
async def
|
| 1377 |
body = await request.json()
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
|
|
|
| 1390 |
try:
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1394 |
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import hashlib
|
| 5 |
import json
|
| 6 |
import os
|
| 7 |
import sqlite3
|
|
|
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
import httpx
|
| 16 |
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
|
| 17 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 18 |
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
| 19 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
| 26 |
NVIDIA_API_BASE = RAW_NVIDIA_API_BASE if RAW_NVIDIA_API_BASE.endswith("/v1") else f"{RAW_NVIDIA_API_BASE}/v1"
|
| 27 |
CHAT_COMPLETIONS_URL = f"{NVIDIA_API_BASE}/chat/completions"
|
| 28 |
MODELS_URL = f"{NVIDIA_API_BASE}/models"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "90"))
|
| 30 |
+
MAX_UPSTREAM_CONNECTIONS = int(os.getenv("MAX_UPSTREAM_CONNECTIONS", "512"))
|
| 31 |
+
MAX_KEEPALIVE_CONNECTIONS = int(os.getenv("MAX_KEEPALIVE_CONNECTIONS", "128"))
|
| 32 |
+
MODEL_SYNC_INTERVAL_MINUTES = int(os.getenv("MODEL_SYNC_INTERVAL_MINUTES", "30"))
|
| 33 |
+
PUBLIC_HISTORY_BUCKETS = int(os.getenv("PUBLIC_HISTORY_BUCKETS", "6"))
|
| 34 |
+
BUCKET_MINUTES = 10
|
| 35 |
+
DEFAULT_MONITORED_MODELS = "z-ai/glm5,z-ai/glm4.7,minimaxai/minimax-m2.5,minimaxai/minimax-m2.7,moonshotai/kimi-k2.5,deepseek-ai/deepseek-v3.2,google/gemma-4-31b-it,qwen/qwen3.5-397b-a17b"
|
| 36 |
+
MODEL_LIST = [item.strip() for item in os.getenv("MODEL_LIST", DEFAULT_MONITORED_MODELS).split(",") if item.strip()]
|
| 37 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
http_client: httpx.AsyncClient | None = None
|
| 39 |
+
model_cache: list[dict[str, Any]] = []
|
| 40 |
+
model_cache_synced_at: str | None = None
|
| 41 |
+
model_cache_lock: asyncio.Lock | None = None
|
| 42 |
+
model_sync_task: asyncio.Task[None] | None = None
|
| 43 |
|
| 44 |
|
| 45 |
def utcnow() -> datetime:
|
|
|
|
| 50 |
return utcnow().isoformat()
|
| 51 |
|
| 52 |
|
| 53 |
+
def json_dumps(value: Any) -> str:
|
| 54 |
+
return json.dumps(value, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
+
def hash_api_key(api_key: str) -> str:
|
| 58 |
+
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
+
def normalize_provider(model_id: str, owned_by: str | None = None) -> str:
|
| 62 |
+
if owned_by:
|
| 63 |
+
return owned_by
|
| 64 |
+
if "/" in model_id:
|
| 65 |
+
return model_id.split("/", 1)[0]
|
| 66 |
+
return "unknown"
|
| 67 |
|
| 68 |
|
| 69 |
+
def bucket_start(dt: datetime | None = None) -> datetime:
|
| 70 |
+
dt = dt or utcnow()
|
| 71 |
+
minute = dt.minute - (dt.minute % BUCKET_MINUTES)
|
| 72 |
+
return dt.replace(minute=minute, second=0, microsecond=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
+
def bucket_label(value: str) -> str:
|
| 76 |
+
try:
|
| 77 |
+
dt = datetime.fromisoformat(value)
|
| 78 |
+
except ValueError:
|
| 79 |
+
return value
|
| 80 |
+
return dt.strftime("%H:%M")
|
| 81 |
|
| 82 |
|
| 83 |
def get_db_connection() -> sqlite3.Connection:
|
| 84 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30.0)
|
| 86 |
conn.row_factory = sqlite3.Row
|
| 87 |
conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
def init_db() -> None:
|
|
|
|
| 95 |
conn = get_db_connection()
|
| 96 |
try:
|
| 97 |
conn.executescript(
|
| 98 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
CREATE TABLE IF NOT EXISTS response_records (
|
| 100 |
+
response_id TEXT PRIMARY KEY,
|
| 101 |
+
api_key_hash TEXT NOT NULL,
|
| 102 |
parent_response_id TEXT,
|
| 103 |
+
model_id TEXT NOT NULL,
|
|
|
|
| 104 |
request_json TEXT NOT NULL,
|
| 105 |
input_items_json TEXT NOT NULL,
|
| 106 |
output_json TEXT NOT NULL,
|
| 107 |
output_items_json TEXT NOT NULL,
|
| 108 |
status TEXT NOT NULL,
|
| 109 |
+
success INTEGER NOT NULL,
|
| 110 |
+
latency_ms REAL,
|
| 111 |
+
error_message TEXT,
|
| 112 |
created_at TEXT NOT NULL
|
| 113 |
);
|
| 114 |
|
| 115 |
+
CREATE INDEX IF NOT EXISTS idx_response_api_hash ON response_records(api_key_hash);
|
| 116 |
+
CREATE INDEX IF NOT EXISTS idx_response_parent ON response_records(parent_response_id);
|
| 117 |
+
CREATE INDEX IF NOT EXISTS idx_response_model_created ON response_records(model_id, created_at);
|
| 118 |
+
|
| 119 |
+
CREATE TABLE IF NOT EXISTS metric_buckets (
|
| 120 |
+
bucket_start TEXT NOT NULL,
|
| 121 |
+
model_id TEXT NOT NULL,
|
| 122 |
+
total_count INTEGER NOT NULL DEFAULT 0,
|
| 123 |
+
success_count INTEGER NOT NULL DEFAULT 0,
|
| 124 |
+
total_latency_ms REAL NOT NULL DEFAULT 0,
|
| 125 |
+
PRIMARY KEY(bucket_start, model_id)
|
| 126 |
);
|
| 127 |
|
| 128 |
+
CREATE TABLE IF NOT EXISTS gateway_totals (
|
| 129 |
+
id INTEGER PRIMARY KEY CHECK(id = 1),
|
| 130 |
+
total_requests INTEGER NOT NULL DEFAULT 0,
|
| 131 |
+
total_success INTEGER NOT NULL DEFAULT 0,
|
| 132 |
+
total_latency_ms REAL NOT NULL DEFAULT 0,
|
| 133 |
+
updated_at TEXT NOT NULL
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
CREATE TABLE IF NOT EXISTS official_models_cache (
|
| 137 |
+
id TEXT PRIMARY KEY,
|
| 138 |
+
object TEXT NOT NULL,
|
| 139 |
+
created INTEGER,
|
| 140 |
+
owned_by TEXT,
|
| 141 |
+
synced_at TEXT NOT NULL
|
| 142 |
);
|
| 143 |
"""
|
| 144 |
)
|
| 145 |
+
conn.execute(
|
| 146 |
+
"""
|
| 147 |
+
INSERT OR IGNORE INTO gateway_totals (id, total_requests, total_success, total_latency_ms, updated_at)
|
| 148 |
+
VALUES (1, 0, 0, 0, ?)
|
| 149 |
+
""",
|
| 150 |
+
(utcnow_iso(),),
|
| 151 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
conn.commit()
|
| 153 |
finally:
|
| 154 |
conn.close()
|
| 155 |
|
| 156 |
|
| 157 |
+
async def run_db(fn, *args, **kwargs):
|
| 158 |
+
return await asyncio.to_thread(fn, *args, **kwargs)
|
|
|
|
|
|
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
+
async def get_http_client() -> httpx.AsyncClient:
|
| 162 |
+
global http_client
|
| 163 |
+
if http_client is None or http_client.is_closed:
|
| 164 |
+
limits = httpx.Limits(
|
| 165 |
+
max_connections=MAX_UPSTREAM_CONNECTIONS,
|
| 166 |
+
max_keepalive_connections=MAX_KEEPALIVE_CONNECTIONS,
|
| 167 |
+
)
|
| 168 |
+
http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS, limits=limits)
|
| 169 |
+
return http_client
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
async def get_model_cache_lock() -> asyncio.Lock:
|
| 173 |
+
global model_cache_lock
|
| 174 |
+
if model_cache_lock is None:
|
| 175 |
+
model_cache_lock = asyncio.Lock()
|
| 176 |
+
return model_cache_lock
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
def load_cached_models_from_db() -> tuple[list[dict[str, Any]], str | None]:
|
| 180 |
+
conn = get_db_connection()
|
| 181 |
+
try:
|
| 182 |
+
rows = conn.execute(
|
| 183 |
+
"SELECT id, object, created, owned_by, synced_at FROM official_models_cache ORDER BY id ASC"
|
| 184 |
+
).fetchall()
|
| 185 |
+
if not rows:
|
| 186 |
+
return [], None
|
| 187 |
+
synced_at = rows[0]["synced_at"]
|
| 188 |
+
models = [
|
| 189 |
+
{
|
| 190 |
+
"id": row["id"],
|
| 191 |
+
"object": row["object"],
|
| 192 |
+
"created": row["created"],
|
| 193 |
+
"owned_by": row["owned_by"],
|
| 194 |
+
}
|
| 195 |
+
for row in rows
|
| 196 |
+
]
|
| 197 |
+
return models, synced_at
|
| 198 |
+
finally:
|
| 199 |
+
conn.close()
|
| 200 |
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
+
def save_models_to_db(models: list[dict[str, Any]], synced_at: str) -> None:
|
| 203 |
+
unique_models: dict[str, dict[str, Any]] = {}
|
| 204 |
+
for model in models:
|
| 205 |
+
model_id = model.get("id")
|
| 206 |
+
if model_id:
|
| 207 |
+
unique_models[model_id] = model
|
| 208 |
|
| 209 |
+
conn = get_db_connection()
|
|
|
|
| 210 |
try:
|
| 211 |
+
conn.execute("DELETE FROM official_models_cache")
|
| 212 |
+
conn.executemany(
|
| 213 |
+
"""
|
| 214 |
+
INSERT INTO official_models_cache (id, object, created, owned_by, synced_at)
|
| 215 |
+
VALUES (?, ?, ?, ?, ?)
|
| 216 |
+
""",
|
| 217 |
+
[
|
| 218 |
+
(
|
| 219 |
+
model_id,
|
| 220 |
+
model.get("object", "model"),
|
| 221 |
+
model.get("created"),
|
| 222 |
+
model.get("owned_by") or normalize_provider(model_id),
|
| 223 |
+
synced_at,
|
| 224 |
+
)
|
| 225 |
+
for model_id, model in sorted(unique_models.items(), key=lambda item: item[0])
|
| 226 |
+
],
|
| 227 |
+
)
|
| 228 |
+
conn.commit()
|
| 229 |
+
finally:
|
| 230 |
+
conn.close()
|
| 231 |
|
| 232 |
|
| 233 |
+
async def refresh_official_models(force: bool = False) -> list[dict[str, Any]]:
|
| 234 |
+
global model_cache, model_cache_synced_at
|
| 235 |
+
if model_cache and not force:
|
| 236 |
+
return model_cache
|
| 237 |
+
lock = await get_model_cache_lock()
|
| 238 |
+
async with lock:
|
| 239 |
+
if model_cache and not force:
|
| 240 |
+
return model_cache
|
| 241 |
+
client = await get_http_client()
|
| 242 |
+
response = await client.get(MODELS_URL, headers={"Accept": "application/json"})
|
| 243 |
+
response.raise_for_status()
|
| 244 |
+
payload = response.json()
|
| 245 |
+
models = payload.get("data") or payload.get("models") or []
|
| 246 |
+
normalized = [
|
| 247 |
+
{
|
| 248 |
+
"id": item.get("id"),
|
| 249 |
+
"object": item.get("object", "model"),
|
| 250 |
+
"created": item.get("created"),
|
| 251 |
+
"owned_by": item.get("owned_by") or normalize_provider(item.get("id", "")),
|
| 252 |
+
}
|
| 253 |
+
for item in models
|
| 254 |
+
if isinstance(item, dict) and item.get("id")
|
| 255 |
+
]
|
| 256 |
+
synced_at = utcnow_iso()
|
| 257 |
+
await run_db(save_models_to_db, normalized, synced_at)
|
| 258 |
+
model_cache = normalized
|
| 259 |
+
model_cache_synced_at = synced_at
|
| 260 |
+
return normalized
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def model_sync_loop() -> None:
|
| 264 |
+
while True:
|
| 265 |
+
try:
|
| 266 |
+
await refresh_official_models(force=True)
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
| 269 |
+
await asyncio.sleep(max(300, MODEL_SYNC_INTERVAL_MINUTES * 60))
|
| 270 |
|
| 271 |
|
| 272 |
+
def extract_user_api_key(
|
| 273 |
+
authorization: str | None = Header(default=None),
|
| 274 |
+
x_api_key: str | None = Header(default=None),
|
| 275 |
+
x_nvidia_api_key: str | None = Header(default=None),
|
| 276 |
+
) -> str:
|
| 277 |
token: str | None = None
|
| 278 |
if authorization and authorization.startswith("Bearer "):
|
| 279 |
token = authorization.removeprefix("Bearer ").strip()
|
| 280 |
elif x_api_key:
|
| 281 |
token = x_api_key.strip()
|
| 282 |
+
elif x_nvidia_api_key:
|
| 283 |
+
token = x_nvidia_api_key.strip()
|
| 284 |
if not token:
|
| 285 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请通过 Authorization Bearer 或 X-API-Key 提供你的 NIM Key。")
|
| 286 |
+
return token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
def normalize_content(content: Any, role: str) -> list[dict[str, Any]]:
|
| 289 |
if content is None:
|
|
|
|
| 328 |
if not isinstance(item, dict):
|
| 329 |
items.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": str(item)}]})
|
| 330 |
continue
|
|
|
|
| 331 |
item_type = item.get("type")
|
| 332 |
if item_type == "message" or item.get("role"):
|
| 333 |
role = item.get("role", "user")
|
|
|
|
| 343 |
arguments = item.get("arguments", "{}")
|
| 344 |
if not isinstance(arguments, str):
|
| 345 |
arguments = json_dumps(arguments)
|
| 346 |
+
items.append({
|
| 347 |
+
"type": "function_call",
|
| 348 |
+
"call_id": item.get("call_id") or f"call_{uuid.uuid4().hex[:12]}",
|
| 349 |
+
"name": item.get("name"),
|
| 350 |
+
"arguments": arguments,
|
| 351 |
+
})
|
|
|
|
|
|
|
| 352 |
continue
|
| 353 |
if item_type in {"input_text", "output_text", "text"}:
|
| 354 |
items.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": item.get("text", "")}]})
|
|
|
|
| 377 |
return str(content)
|
| 378 |
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
def items_to_chat_messages(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 381 |
messages: list[dict[str, Any]] = []
|
| 382 |
pending_tool_calls: list[dict[str, Any]] = []
|
|
|
|
| 522 |
arguments = part.get("arguments") or "{}"
|
| 523 |
if not isinstance(arguments, str):
|
| 524 |
arguments = json_dumps(arguments)
|
| 525 |
+
tool_calls.append({
|
| 526 |
+
"id": part.get("id") or part.get("call_id") or f"call_{uuid.uuid4().hex[:12]}",
|
| 527 |
+
"name": part.get("name"),
|
| 528 |
+
"arguments": arguments,
|
| 529 |
+
})
|
| 530 |
|
| 531 |
for tool_call in message.get("tool_calls") or []:
|
| 532 |
if not isinstance(tool_call, dict):
|
|
|
|
| 535 |
arguments = function_data.get("arguments") or tool_call.get("arguments") or "{}"
|
| 536 |
if not isinstance(arguments, str):
|
| 537 |
arguments = json_dumps(arguments)
|
| 538 |
+
tool_calls.append(
|
| 539 |
+
{
|
| 540 |
+
"id": tool_call.get("id") or f"call_{uuid.uuid4().hex[:12]}",
|
| 541 |
+
"name": function_data.get("name") or tool_call.get("name"),
|
| 542 |
+
"arguments": arguments,
|
| 543 |
+
}
|
| 544 |
+
)
|
| 545 |
|
| 546 |
deduped: list[dict[str, Any]] = []
|
| 547 |
seen_ids: set[str] = set()
|
|
|
|
| 552 |
deduped.append(tool_call)
|
| 553 |
return "\n".join(filter(None, text_chunks)).strip(), deduped
|
| 554 |
|
|
|
|
| 555 |
def build_choice_alias(output_items: list[dict[str, Any]], finish_reason: str | None) -> list[dict[str, Any]]:
|
| 556 |
content_parts: list[dict[str, Any]] = []
|
| 557 |
for item in output_items:
|
|
|
|
| 574 |
response_id = upstream_json.get("id") or f"resp_{uuid.uuid4().hex}"
|
| 575 |
output_items: list[dict[str, Any]] = []
|
| 576 |
if assistant_text:
|
| 577 |
+
output_items.append({
|
| 578 |
+
"id": f"msg_{uuid.uuid4().hex[:24]}",
|
| 579 |
+
"type": "message",
|
| 580 |
+
"status": "completed",
|
| 581 |
+
"role": "assistant",
|
| 582 |
+
"content": [{"type": "output_text", "text": assistant_text, "annotations": []}],
|
| 583 |
+
})
|
| 584 |
for tool_call in tool_calls:
|
| 585 |
+
output_items.append({
|
| 586 |
+
"id": f"fc_{uuid.uuid4().hex[:24]}",
|
| 587 |
+
"type": "function_call",
|
| 588 |
+
"status": "completed",
|
| 589 |
+
"call_id": tool_call["id"],
|
| 590 |
+
"name": tool_call.get("name"),
|
| 591 |
+
"arguments": tool_call.get("arguments", "{}"),
|
| 592 |
+
})
|
| 593 |
usage = upstream_json.get("usage") or {}
|
| 594 |
return {
|
| 595 |
"id": response_id,
|
|
|
|
| 603 |
"previous_response_id": previous_response_id,
|
| 604 |
"store": True,
|
| 605 |
"text": body.get("text") or {"format": {"type": "text"}},
|
| 606 |
+
"usage": {
|
| 607 |
+
"input_tokens": usage.get("prompt_tokens"),
|
| 608 |
+
"output_tokens": usage.get("completion_tokens"),
|
| 609 |
+
"total_tokens": usage.get("total_tokens"),
|
| 610 |
+
},
|
| 611 |
"choices": build_choice_alias(output_items, finish_reason),
|
| 612 |
+
"upstream": {
|
| 613 |
+
"id": upstream_json.get("id"),
|
| 614 |
+
"object": upstream_json.get("object", "chat.completion"),
|
| 615 |
+
"finish_reason": finish_reason or "stop",
|
| 616 |
+
},
|
| 617 |
}
|
| 618 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
|
| 620 |
+
def store_success_record(api_key_hash: str, model_id: str, request_body: dict[str, Any], input_items: list[dict[str, Any]], response_payload: dict[str, Any], latency_ms: float) -> None:
|
| 621 |
+
conn = get_db_connection()
|
| 622 |
+
try:
|
| 623 |
+
now = utcnow_iso()
|
| 624 |
+
bucket = bucket_start().isoformat()
|
| 625 |
conn.execute(
|
| 626 |
"""
|
| 627 |
+
INSERT OR REPLACE INTO response_records (
|
| 628 |
+
response_id, api_key_hash, parent_response_id, model_id, request_json,
|
| 629 |
+
input_items_json, output_json, output_items_json, status, success,
|
| 630 |
+
latency_ms, error_message, created_at
|
| 631 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
|
|
|
|
|
| 632 |
""",
|
| 633 |
+
(
|
| 634 |
+
response_payload["id"],
|
| 635 |
+
api_key_hash,
|
| 636 |
+
request_body.get("previous_response_id"),
|
| 637 |
+
model_id,
|
| 638 |
+
json_dumps(request_body),
|
| 639 |
+
json_dumps(input_items),
|
| 640 |
+
json_dumps(response_payload),
|
| 641 |
+
json_dumps(response_payload.get("output") or []),
|
| 642 |
+
response_payload.get("status", "completed"),
|
| 643 |
+
1,
|
| 644 |
+
latency_ms,
|
| 645 |
+
None,
|
| 646 |
+
now,
|
| 647 |
+
),
|
| 648 |
)
|
| 649 |
conn.execute(
|
| 650 |
"""
|
| 651 |
+
INSERT INTO metric_buckets (bucket_start, model_id, total_count, success_count, total_latency_ms)
|
| 652 |
+
VALUES (?, ?, 1, 1, ?)
|
| 653 |
+
ON CONFLICT(bucket_start, model_id) DO UPDATE SET
|
| 654 |
+
total_count = total_count + 1,
|
| 655 |
+
success_count = success_count + 1,
|
| 656 |
+
total_latency_ms = total_latency_ms + excluded.total_latency_ms
|
| 657 |
+
""",
|
| 658 |
+
(bucket, model_id, latency_ms),
|
| 659 |
+
)
|
| 660 |
+
conn.execute(
|
| 661 |
+
"""
|
| 662 |
+
UPDATE gateway_totals
|
| 663 |
+
SET total_requests = total_requests + 1,
|
| 664 |
+
total_success = total_success + 1,
|
| 665 |
+
total_latency_ms = total_latency_ms + ?,
|
| 666 |
updated_at = ?
|
| 667 |
+
WHERE id = 1
|
| 668 |
""",
|
| 669 |
+
(latency_ms, now),
|
| 670 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
finally:
|
| 673 |
conn.close()
|
| 674 |
|
| 675 |
|
| 676 |
+
def store_failure_metric(model_id: str, error_message: str) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
conn = get_db_connection()
|
| 678 |
try:
|
| 679 |
now = utcnow_iso()
|
| 680 |
+
bucket = bucket_start().isoformat()
|
| 681 |
+
conn.execute(
|
| 682 |
+
"""
|
| 683 |
+
INSERT INTO metric_buckets (bucket_start, model_id, total_count, success_count, total_latency_ms)
|
| 684 |
+
VALUES (?, ?, 1, 0, 0)
|
| 685 |
+
ON CONFLICT(bucket_start, model_id) DO UPDATE SET
|
| 686 |
+
total_count = total_count + 1
|
| 687 |
+
""",
|
| 688 |
+
(bucket, model_id),
|
| 689 |
+
)
|
| 690 |
conn.execute(
|
| 691 |
"""
|
| 692 |
+
UPDATE gateway_totals
|
| 693 |
+
SET total_requests = total_requests + 1,
|
| 694 |
+
updated_at = ?
|
| 695 |
+
WHERE id = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
""",
|
| 697 |
+
(now,),
|
| 698 |
)
|
| 699 |
conn.commit()
|
|
|
|
|
|
|
| 700 |
finally:
|
| 701 |
conn.close()
|
| 702 |
|
| 703 |
|
| 704 |
+
def load_previous_conversation_items(api_key_hash: str, previous_response_id: str | None) -> list[dict[str, Any]]:
|
| 705 |
+
if not previous_response_id:
|
| 706 |
+
return []
|
| 707 |
conn = get_db_connection()
|
| 708 |
try:
|
| 709 |
+
items: list[dict[str, Any]] = []
|
| 710 |
+
current = previous_response_id
|
| 711 |
+
chain: list[sqlite3.Row] = []
|
| 712 |
+
while current:
|
| 713 |
+
row = conn.execute(
|
| 714 |
+
"SELECT * FROM response_records WHERE response_id = ? AND api_key_hash = ?",
|
| 715 |
+
(current, api_key_hash),
|
| 716 |
+
).fetchone()
|
| 717 |
+
if not row:
|
| 718 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"previous_response_id '{current}' 不存在,或不属于当前 Key。")
|
| 719 |
+
chain.append(row)
|
| 720 |
+
current = row["parent_response_id"]
|
| 721 |
+
for row in reversed(chain):
|
| 722 |
+
items.extend(json.loads(row["input_items_json"]))
|
| 723 |
+
items.extend(json.loads(row["output_items_json"]))
|
| 724 |
+
return items
|
| 725 |
finally:
|
| 726 |
conn.close()
|
| 727 |
|
| 728 |
|
| 729 |
+
def load_response_record(api_key_hash: str, response_id: str) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
conn = get_db_connection()
|
| 731 |
try:
|
| 732 |
+
row = conn.execute(
|
| 733 |
+
"SELECT output_json FROM response_records WHERE response_id = ? AND api_key_hash = ?",
|
| 734 |
+
(response_id, api_key_hash),
|
| 735 |
+
).fetchone()
|
| 736 |
if not row:
|
| 737 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到对应响应,或当前 Key 无权访问。")
|
| 738 |
+
return json.loads(row["output_json"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
finally:
|
| 740 |
conn.close()
|
| 741 |
|
| 742 |
|
| 743 |
+
def load_dashboard_data() -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
conn = get_db_connection()
|
| 745 |
try:
|
| 746 |
+
totals_row = conn.execute("SELECT * FROM gateway_totals WHERE id = 1").fetchone()
|
| 747 |
+
total_requests = totals_row["total_requests"] if totals_row else 0
|
| 748 |
+
now_bucket = bucket_start()
|
| 749 |
+
bucket_points = [(now_bucket - timedelta(minutes=BUCKET_MINUTES * offset)).isoformat() for offset in reversed(range(PUBLIC_HISTORY_BUCKETS))]
|
| 750 |
+
placeholders = ",".join("?" for _ in MODEL_LIST) if MODEL_LIST else "''"
|
| 751 |
+
totals_by_model = {
|
| 752 |
+
row["model_id"]: row["total_count"]
|
| 753 |
+
for row in conn.execute(
|
| 754 |
+
f"SELECT model_id, COALESCE(SUM(total_count), 0) AS total_count FROM metric_buckets WHERE model_id IN ({placeholders}) GROUP BY model_id",
|
| 755 |
+
MODEL_LIST,
|
| 756 |
+
).fetchall()
|
| 757 |
+
} if MODEL_LIST else {}
|
| 758 |
+
since = bucket_points[0] if bucket_points else utcnow_iso()
|
| 759 |
+
recent_rows = conn.execute(
|
| 760 |
+
f"SELECT bucket_start, model_id, total_count, success_count FROM metric_buckets WHERE model_id IN ({placeholders}) AND bucket_start >= ? ORDER BY bucket_start ASC",
|
| 761 |
+
[*MODEL_LIST, since],
|
| 762 |
+
).fetchall() if MODEL_LIST else []
|
| 763 |
+
row_map: dict[str, dict[str, sqlite3.Row]] = {}
|
| 764 |
+
for row in recent_rows:
|
| 765 |
+
row_map.setdefault(row["model_id"], {})[row["bucket_start"]] = row
|
| 766 |
+
models: list[dict[str, Any]] = []
|
| 767 |
+
latest_rates: list[float] = []
|
| 768 |
+
for model_id in MODEL_LIST:
|
| 769 |
+
points: list[dict[str, Any]] = []
|
| 770 |
+
latest_rate: float | None = None
|
| 771 |
+
for bucket_value in bucket_points:
|
| 772 |
+
row = row_map.get(model_id, {}).get(bucket_value)
|
| 773 |
+
total_count = row["total_count"] if row else 0
|
| 774 |
+
success_count = row["success_count"] if row else 0
|
| 775 |
+
success_rate = round((success_count / total_count) * 100, 1) if total_count else None
|
| 776 |
+
points.append(
|
| 777 |
+
{
|
| 778 |
+
"bucket_start": bucket_value,
|
| 779 |
+
"label": bucket_label(bucket_value),
|
| 780 |
+
"total_count": total_count,
|
| 781 |
+
"success_count": success_count,
|
| 782 |
+
"success_rate": success_rate,
|
| 783 |
+
}
|
| 784 |
+
)
|
| 785 |
+
if total_count:
|
| 786 |
+
latest_rate = success_rate
|
| 787 |
+
if latest_rate is not None:
|
| 788 |
+
latest_rates.append(latest_rate)
|
| 789 |
+
average_rate = None
|
| 790 |
+
non_empty = [point["success_rate"] for point in points if point["success_rate"] is not None]
|
| 791 |
+
if non_empty:
|
| 792 |
+
average_rate = round(sum(non_empty) / len(non_empty), 1)
|
| 793 |
+
models.append(
|
| 794 |
+
{
|
| 795 |
+
"model_id": model_id,
|
| 796 |
+
"provider": normalize_provider(model_id),
|
| 797 |
+
"total_calls": totals_by_model.get(model_id, 0),
|
| 798 |
+
"latest_success_rate": latest_rate,
|
| 799 |
+
"average_success_rate": average_rate,
|
| 800 |
+
"points": points,
|
| 801 |
+
}
|
| 802 |
+
)
|
| 803 |
+
average_health = round(sum(latest_rates) / len(latest_rates), 1) if latest_rates else None
|
| 804 |
+
return {
|
| 805 |
+
"generated_at": utcnow_iso(),
|
| 806 |
+
"bucket_minutes": BUCKET_MINUTES,
|
| 807 |
+
"total_requests": total_requests,
|
| 808 |
+
"average_health": average_health,
|
| 809 |
+
"models": models,
|
| 810 |
+
}
|
| 811 |
finally:
|
| 812 |
conn.close()
|
| 813 |
|
| 814 |
|
| 815 |
+
def build_catalog_payload() -> dict[str, Any]:
|
| 816 |
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
| 817 |
+
for model in sorted(model_cache, key=lambda item: item.get("id", "")):
|
| 818 |
+
provider = normalize_provider(model.get("id", ""), model.get("owned_by"))
|
| 819 |
+
grouped.setdefault(provider, []).append(model)
|
| 820 |
+
providers = [
|
| 821 |
+
{
|
| 822 |
+
"provider": provider,
|
| 823 |
+
"count": len(items),
|
| 824 |
+
"models": items,
|
| 825 |
+
}
|
| 826 |
+
for provider, items in sorted(grouped.items(), key=lambda entry: entry[0].lower())
|
| 827 |
+
]
|
| 828 |
+
return {
|
| 829 |
+
"generated_at": utcnow_iso(),
|
| 830 |
+
"synced_at": model_cache_synced_at,
|
| 831 |
+
"total_models": len(model_cache),
|
| 832 |
+
"providers": providers,
|
| 833 |
+
}
|
| 834 |
|
| 835 |
|
| 836 |
+
async def post_nvidia_chat_completion(api_key: str, payload: dict[str, Any]) -> tuple[dict[str, Any], float]:
|
| 837 |
+
client = await get_http_client()
|
| 838 |
+
started = time.perf_counter()
|
| 839 |
+
response = await client.post(
|
| 840 |
+
CHAT_COMPLETIONS_URL,
|
| 841 |
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json"},
|
| 842 |
+
json=payload,
|
| 843 |
+
)
|
| 844 |
+
latency_ms = round((time.perf_counter() - started) * 1000, 2)
|
| 845 |
+
if response.status_code >= 400:
|
| 846 |
+
try:
|
| 847 |
+
error_payload = response.json()
|
| 848 |
+
detail = error_payload.get("error", {}).get("message") or json_dumps(error_payload)
|
| 849 |
+
except Exception:
|
| 850 |
+
detail = response.text
|
| 851 |
+
raise HTTPException(status_code=response.status_code, detail=f"NVIDIA NIM 请求失败:{detail}")
|
| 852 |
+
return response.json(), latency_ms
|
| 853 |
|
| 854 |
|
| 855 |
+
def render_html(filename: str) -> HTMLResponse:
|
| 856 |
+
content = (STATIC_DIR / filename).read_text(encoding="utf-8")
|
| 857 |
+
return HTMLResponse(content=content, media_type="text/html; charset=utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
|
| 859 |
|
| 860 |
+
@asynccontextmanager
|
| 861 |
+
async def lifespan(_app: FastAPI):
|
| 862 |
+
global model_cache, model_cache_synced_at, model_sync_task, http_client, model_cache_lock
|
| 863 |
+
init_db()
|
| 864 |
+
cached_models, cached_synced_at = await run_db(load_cached_models_from_db)
|
| 865 |
+
model_cache = cached_models
|
| 866 |
+
model_cache_synced_at = cached_synced_at
|
| 867 |
+
model_cache_lock = asyncio.Lock()
|
| 868 |
+
http_client = await get_http_client()
|
| 869 |
try:
|
| 870 |
+
await refresh_official_models(force=not bool(model_cache))
|
| 871 |
+
except Exception:
|
| 872 |
+
pass
|
| 873 |
+
model_sync_task = asyncio.create_task(model_sync_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
try:
|
| 875 |
+
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
finally:
|
| 877 |
+
if model_sync_task is not None:
|
| 878 |
+
model_sync_task.cancel()
|
| 879 |
+
with contextlib.suppress(asyncio.CancelledError):
|
| 880 |
+
await model_sync_task
|
| 881 |
+
if http_client is not None and not http_client.is_closed:
|
| 882 |
+
await http_client.aclose()
|
| 883 |
+
http_client = None
|
| 884 |
+
model_sync_task = None
|
| 885 |
+
model_cache_lock = None
|
| 886 |
|
| 887 |
|
| 888 |
+
app = FastAPI(title="NIM Responses Gateway", lifespan=lifespan)
|
| 889 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 890 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
|
| 892 |
|
| 893 |
+
@app.get("/", response_class=HTMLResponse)
|
| 894 |
+
async def homepage() -> HTMLResponse:
|
| 895 |
+
return render_html("index.html")
|
|
|
|
|
|
|
| 896 |
|
| 897 |
|
| 898 |
+
@app.get("/api/dashboard")
|
| 899 |
+
async def dashboard_api() -> dict[str, Any]:
|
| 900 |
+
return await run_db(load_dashboard_data)
|
|
|
|
| 901 |
|
| 902 |
|
| 903 |
+
@app.get("/api/catalog")
|
| 904 |
+
async def catalog_api() -> dict[str, Any]:
|
| 905 |
+
if not model_cache:
|
| 906 |
+
try:
|
| 907 |
+
await refresh_official_models(force=True)
|
| 908 |
+
except Exception:
|
| 909 |
+
pass
|
| 910 |
+
return build_catalog_payload()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 911 |
|
| 912 |
|
| 913 |
+
@app.get("/v1/models")
|
| 914 |
+
async def list_models() -> dict[str, Any]:
|
| 915 |
+
if not model_cache:
|
| 916 |
+
await refresh_official_models(force=True)
|
| 917 |
+
return {"object": "list", "data": model_cache}
|
| 918 |
|
| 919 |
|
| 920 |
+
@app.get("/v1/responses/{response_id}")
|
| 921 |
+
async def get_response(response_id: str, api_key: str = Depends(extract_user_api_key)) -> dict[str, Any]:
|
| 922 |
+
return await run_db(load_response_record, hash_api_key(api_key), response_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
|
| 924 |
|
| 925 |
+
@app.post("/v1/responses")
|
| 926 |
+
async def create_response(request: Request, api_key: str = Depends(extract_user_api_key)):
|
| 927 |
body = await request.json()
|
| 928 |
+
if not isinstance(body, dict):
|
| 929 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请求体必须是 JSON 对象。")
|
| 930 |
+
if not body.get("model"):
|
| 931 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少 model 字段。")
|
| 932 |
+
if body.get("input") is None:
|
| 933 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少 input 字段。")
|
| 934 |
+
|
| 935 |
+
api_key_hash = hash_api_key(api_key)
|
| 936 |
+
input_items = normalize_input_items(body.get("input"))
|
| 937 |
+
previous_items = await run_db(load_previous_conversation_items, api_key_hash, body.get("previous_response_id"))
|
| 938 |
+
merged_items = previous_items + input_items
|
| 939 |
+
chat_payload = build_chat_payload(body, merged_items)
|
| 940 |
+
|
| 941 |
try:
|
| 942 |
+
upstream_json, latency_ms = await post_nvidia_chat_completion(api_key, chat_payload)
|
| 943 |
+
except HTTPException as exc:
|
| 944 |
+
await run_db(store_failure_metric, body.get("model"), exc.detail)
|
| 945 |
+
raise exc
|
| 946 |
+
|
| 947 |
+
response_payload = chat_completion_to_response(body, upstream_json, body.get("previous_response_id"))
|
| 948 |
+
await run_db(store_success_record, api_key_hash, body.get("model"), body, input_items, response_payload, latency_ms)
|
| 949 |
+
|
| 950 |
+
if body.get("stream"):
|
| 951 |
+
async def event_stream() -> Any:
|
| 952 |
+
yield f"event: response.created\ndata: {json_dumps({'type': 'response.created', 'response': {'id': response_payload['id'], 'model': response_payload['model'], 'status': 'in_progress'}})}\n\n"
|
| 953 |
+
for index, item in enumerate(response_payload.get("output") or []):
|
| 954 |
+
yield f"event: response.output_item.added\ndata: {json_dumps({'type': 'response.output_item.added', 'output_index': index, 'item': item})}\n\n"
|
| 955 |
+
if item.get("type") == "message":
|
| 956 |
+
text_value = extract_text_from_content(item.get("content"))
|
| 957 |
+
if text_value:
|
| 958 |
+
yield f"event: response.output_text.delta\ndata: {json_dumps({'type': 'response.output_text.delta', 'output_index': index, 'delta': text_value})}\n\n"
|
| 959 |
+
yield f"event: response.output_text.done\ndata: {json_dumps({'type': 'response.output_text.done', 'output_index': index, 'text': text_value})}\n\n"
|
| 960 |
+
if item.get("type") == "function_call":
|
| 961 |
+
yield f"event: response.function_call_arguments.done\ndata: {json_dumps({'type': 'response.function_call_arguments.done', 'output_index': index, 'arguments': item.get('arguments', '{}'), 'call_id': item.get('call_id')})}\n\n"
|
| 962 |
+
yield f"event: response.output_item.done\ndata: {json_dumps({'type': 'response.output_item.done', 'output_index': index, 'item': item})}\n\n"
|
| 963 |
+
yield f"event: response.completed\ndata: {json_dumps({'type': 'response.completed', 'response': response_payload})}\n\n"
|
| 964 |
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 965 |
+
|
| 966 |
+
return response_payload
|
| 967 |
+
|
| 968 |
|
requirements.txt
CHANGED
|
@@ -1,6 +1,3 @@
|
|
| 1 |
-
fastapi>=0.116.0,<1.0.0
|
| 2 |
uvicorn[standard]>=0.35.0,<1.0.0
|
| 3 |
-
httpx>=0.28.1,<1.0.0
|
| 4 |
-
apscheduler>=3.10.4,<4.0.0
|
| 5 |
-
python-multipart>=0.0.20,<1.0.0
|
| 6 |
-
itsdangerous>=2.2.0,<3.0.0
|
|
|
|
| 1 |
+
fastapi>=0.116.0,<1.0.0
|
| 2 |
uvicorn[standard]>=0.35.0,<1.0.0
|
| 3 |
+
httpx>=0.28.1,<1.0.0
|
|
|
|
|
|
|
|
|
static/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
-
<title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link
|
|
@@ -13,52 +13,88 @@
|
|
| 13 |
/>
|
| 14 |
<link rel="stylesheet" href="/static/style.css" />
|
| 15 |
</head>
|
| 16 |
-
<body class="
|
| 17 |
<div class="ambient ambient-left"></div>
|
| 18 |
<div class="ambient ambient-right"></div>
|
| 19 |
-
<
|
| 20 |
-
<section class="hero-panel">
|
| 21 |
-
<div class="hero-copy">
|
| 22 |
-
<span class="hero-badge">NVIDIA NIM 网关</span>
|
| 23 |
-
<h1>模型健康度看板</h1>
|
| 24 |
-
<p>
|
| 25 |
-
公开页面只展示健康状态。系统按小时定时调用 NVIDIA NIM,
|
| 26 |
-
记录模型能否正常响应、最近一次时延,以及过去几个小时的稳定性走势。
|
| 27 |
-
</p>
|
| 28 |
-
</div>
|
| 29 |
-
<div class="hero-side">
|
| 30 |
-
<div class="hero-kicker">监控视图</div>
|
| 31 |
-
<div class="hero-value">小时级可用性</div>
|
| 32 |
-
<p>由后台巡检任务驱动,支持管理员扩展模型列表和更换巡检 Key。</p>
|
| 33 |
-
</div>
|
| 34 |
-
</section>
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
</div>
|
| 47 |
-
<div class="summary-strip" id="summary-chips"></div>
|
| 48 |
-
</section>
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
<
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</main>
|
|
|
|
| 62 |
<script src="/static/public.js" charset="utf-8" defer></script>
|
| 63 |
</body>
|
| 64 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>NIM 模型健康与模型库</title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link
|
|
|
|
| 13 |
/>
|
| 14 |
<link rel="stylesheet" href="/static/style.css" />
|
| 15 |
</head>
|
| 16 |
+
<body class="showcase-body">
|
| 17 |
<div class="ambient ambient-left"></div>
|
| 18 |
<div class="ambient ambient-right"></div>
|
| 19 |
+
<div class="ambient ambient-bottom"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
<header class="topbar">
|
| 22 |
+
<div class="brand-mark">
|
| 23 |
+
<span class="brand-badge">NIM Responses Gateway</span>
|
| 24 |
+
<strong>模型健康与官方模型库</strong>
|
| 25 |
+
</div>
|
| 26 |
+
<nav class="page-nav">
|
| 27 |
+
<button class="page-nav-btn active" data-page="0">健康看板</button>
|
| 28 |
+
<button class="page-nav-btn" data-page="1">官方模型库</button>
|
| 29 |
+
</nav>
|
| 30 |
+
</header>
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
<main class="showcase-shell">
|
| 33 |
+
<div class="page-track" id="page-track">
|
| 34 |
+
<section class="page-section active" data-page="0">
|
| 35 |
+
<section class="hero-stage">
|
| 36 |
+
<div class="hero-copy">
|
| 37 |
+
<span class="hero-badge">每 10 分钟统计</span>
|
| 38 |
+
<h1>实时观察模型调用质量</h1>
|
| 39 |
+
<p>
|
| 40 |
+
本网关不保存任何用户 NIM Key。每次调用都由用户自带 NIM 密钥直连上游,
|
| 41 |
+
页面仅展示聚合后的调用次数、成功率和官方模型目录。
|
| 42 |
+
</p>
|
| 43 |
+
<div class="hero-actions">
|
| 44 |
+
<button class="primary-btn" data-jump="1">查看官方模型库</button>
|
| 45 |
+
<button class="ghost-btn" id="refresh-dashboard" type="button">刷新数据</button>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
<div class="hero-metrics" id="overview-cards"></div>
|
| 49 |
+
</section>
|
| 50 |
+
|
| 51 |
+
<section class="panel-shell">
|
| 52 |
+
<div class="section-head">
|
| 53 |
+
<div>
|
| 54 |
+
<span class="section-tag">健康看板</span>
|
| 55 |
+
<h2>监控模型 10 分钟成功率</h2>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="section-meta">
|
| 58 |
+
<span>最近更新</span>
|
| 59 |
+
<strong id="dashboard-updated">--</strong>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="health-grid" id="health-grid"></div>
|
| 63 |
+
<p class="panel-hint" id="dashboard-empty"></p>
|
| 64 |
+
</section>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
<section class="page-section" data-page="1">
|
| 68 |
+
<section class="hero-stage catalog-stage">
|
| 69 |
+
<div class="hero-copy narrow">
|
| 70 |
+
<span class="hero-badge">官方目录同步</span>
|
| 71 |
+
<h1>来自 NVIDIA 官方的模型列表</h1>
|
| 72 |
+
<p>
|
| 73 |
+
网关会定时从官方 `https://integrate.api.nvidia.com/v1/models` 拉取模型目录,
|
| 74 |
+
并按照模型 ID 对应的提供商进行分类展示。
|
| 75 |
+
</p>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="catalog-summary" id="catalog-summary"></div>
|
| 78 |
+
</section>
|
| 79 |
+
|
| 80 |
+
<section class="panel-shell">
|
| 81 |
+
<div class="section-head">
|
| 82 |
+
<div>
|
| 83 |
+
<span class="section-tag">模型目录</span>
|
| 84 |
+
<h2>按提供商分类</h2>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="section-meta">
|
| 87 |
+
<span>同步时间</span>
|
| 88 |
+
<strong id="catalog-updated">--</strong>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="provider-grid" id="provider-grid"></div>
|
| 92 |
+
<p class="panel-hint" id="catalog-empty"></p>
|
| 93 |
+
</section>
|
| 94 |
+
</section>
|
| 95 |
+
</div>
|
| 96 |
</main>
|
| 97 |
+
|
| 98 |
<script src="/static/public.js" charset="utf-8" defer></script>
|
| 99 |
</body>
|
| 100 |
</html>
|
static/public.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
| 1 |
-
const
|
| 2 |
-
const
|
| 3 |
-
const
|
| 4 |
-
const
|
| 5 |
-
|
| 6 |
-
const
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
};
|
| 19 |
|
| 20 |
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
|
@@ -24,6 +26,9 @@ const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
|
| 24 |
minute: "2-digit",
|
| 25 |
});
|
| 26 |
|
|
|
|
|
|
|
|
|
|
| 27 |
function formatDateTime(value) {
|
| 28 |
if (!value) return "--";
|
| 29 |
const date = new Date(value);
|
|
@@ -31,103 +36,190 @@ function formatDateTime(value) {
|
|
| 31 |
return dateTimeFormatter.format(date);
|
| 32 |
}
|
| 33 |
|
| 34 |
-
function
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
return span;
|
| 41 |
}
|
| 42 |
|
| 43 |
-
function
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
if (
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
function
|
| 67 |
const card = document.createElement("article");
|
| 68 |
-
card.className = "
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</div>
|
| 86 |
-
<div class="
|
| 87 |
-
<
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
-
async function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
try {
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
}
|
| 116 |
-
const payload = await response.json();
|
| 117 |
-
const models = payload.models || [];
|
| 118 |
-
|
| 119 |
-
renderSummary(models);
|
| 120 |
-
modelGrid.innerHTML = "";
|
| 121 |
-
models.forEach((model) => modelGrid.appendChild(renderModel(model)));
|
| 122 |
-
|
| 123 |
-
lastUpdated.textContent = payload.last_updated ? formatDateTime(payload.last_updated) : formatDateTime(new Date().toISOString());
|
| 124 |
-
} catch (_error) {
|
| 125 |
-
errorText.textContent = "当前无法获取 NVIDIA NIM 的巡检结果,请检查后台配置或稍后再试。";
|
| 126 |
-
lastUpdated.textContent = "--";
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
| 130 |
window.addEventListener("DOMContentLoaded", () => {
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
});
|
|
|
|
| 1 |
+
const pageTrack = document.getElementById("page-track");
|
| 2 |
+
const navButtons = document.querySelectorAll(".page-nav-btn");
|
| 3 |
+
const jumpButtons = document.querySelectorAll("[data-jump]");
|
| 4 |
+
const overviewCards = document.getElementById("overview-cards");
|
| 5 |
+
const dashboardUpdated = document.getElementById("dashboard-updated");
|
| 6 |
+
const healthGrid = document.getElementById("health-grid");
|
| 7 |
+
const dashboardEmpty = document.getElementById("dashboard-empty");
|
| 8 |
+
const catalogSummary = document.getElementById("catalog-summary");
|
| 9 |
+
const catalogUpdated = document.getElementById("catalog-updated");
|
| 10 |
+
const providerGrid = document.getElementById("provider-grid");
|
| 11 |
+
const catalogEmpty = document.getElementById("catalog-empty");
|
| 12 |
+
const refreshDashboardBtn = document.getElementById("refresh-dashboard");
|
| 13 |
+
|
| 14 |
+
const STATUS_CLASSES = {
|
| 15 |
+
green: "is-green",
|
| 16 |
+
yellow: "is-yellow",
|
| 17 |
+
orange: "is-orange",
|
| 18 |
+
red: "is-red",
|
| 19 |
+
idle: "is-idle",
|
| 20 |
};
|
| 21 |
|
| 22 |
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
|
|
|
| 26 |
minute: "2-digit",
|
| 27 |
});
|
| 28 |
|
| 29 |
+
let currentPage = 0;
|
| 30 |
+
let wheelLocked = false;
|
| 31 |
+
|
| 32 |
function formatDateTime(value) {
|
| 33 |
if (!value) return "--";
|
| 34 |
const date = new Date(value);
|
|
|
|
| 36 |
return dateTimeFormatter.format(date);
|
| 37 |
}
|
| 38 |
|
| 39 |
+
function rateMeta(rate) {
|
| 40 |
+
if (rate === null || rate === undefined) return { label: "暂无数据", tone: "idle" };
|
| 41 |
+
if (rate >= 95) return { label: "优秀", tone: "green" };
|
| 42 |
+
if (rate >= 80) return { label: "良好", tone: "yellow" };
|
| 43 |
+
if (rate >= 50) return { label: "告警", tone: "orange" };
|
| 44 |
+
return { label: "异常", tone: "red" };
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
+
function switchPage(nextPage) {
|
| 48 |
+
currentPage = Math.max(0, Math.min(1, nextPage));
|
| 49 |
+
pageTrack.style.transform = `translate3d(${-50 * currentPage}%, 0, 0)`;
|
| 50 |
+
navButtons.forEach((button) => button.classList.toggle("active", Number(button.dataset.page) === currentPage));
|
| 51 |
+
document.querySelectorAll(".page-section").forEach((section) => {
|
| 52 |
+
section.classList.toggle("active", Number(section.dataset.page) === currentPage);
|
| 53 |
+
});
|
| 54 |
}
|
| 55 |
|
| 56 |
+
navButtons.forEach((button) => button.addEventListener("click", () => switchPage(Number(button.dataset.page))));
|
| 57 |
+
jumpButtons.forEach((button) => button.addEventListener("click", () => switchPage(Number(button.dataset.jump))));
|
| 58 |
+
|
| 59 |
+
window.addEventListener(
|
| 60 |
+
"wheel",
|
| 61 |
+
(event) => {
|
| 62 |
+
if (wheelLocked) return;
|
| 63 |
+
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || Math.abs(event.deltaY) < 20) return;
|
| 64 |
+
wheelLocked = true;
|
| 65 |
+
switchPage(currentPage + (event.deltaY > 0 ? 1 : -1));
|
| 66 |
+
window.setTimeout(() => {
|
| 67 |
+
wheelLocked = false;
|
| 68 |
+
}, 650);
|
| 69 |
+
},
|
| 70 |
+
{ passive: true }
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
window.addEventListener("keydown", (event) => {
|
| 74 |
+
if (event.key === "ArrowRight") switchPage(1);
|
| 75 |
+
if (event.key === "ArrowLeft") switchPage(0);
|
| 76 |
+
});
|
| 77 |
|
| 78 |
+
function createOverviewCard(label, value, detail = "") {
|
| 79 |
const card = document.createElement("article");
|
| 80 |
+
card.className = "overview-card";
|
| 81 |
+
card.innerHTML = `<span>${label}</span><strong>${value}</strong><p>${detail}</p>`;
|
| 82 |
+
return card;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function renderOverview(data) {
|
| 86 |
+
overviewCards.innerHTML = "";
|
| 87 |
+
const averageHealth = data.average_health === null || data.average_health === undefined ? "--" : `${data.average_health.toFixed(1)}%`;
|
| 88 |
+
const activeModels = (data.models || []).filter((model) => model.latest_success_rate !== null && model.latest_success_rate !== undefined).length;
|
| 89 |
+
const averageLatencyModels = (data.models || [])
|
| 90 |
+
.flatMap((model) => model.points || [])
|
| 91 |
+
.filter((point) => point.total_count > 0 && point.success_rate !== null && point.success_rate !== undefined);
|
| 92 |
+
|
| 93 |
+
overviewCards.appendChild(createOverviewCard("总调用次数", data.total_requests ?? 0, "来自网关历史累计转发记录"));
|
| 94 |
+
overviewCards.appendChild(createOverviewCard("平均健康度", averageHealth, "按监控模型最近 10 分钟成功率平均值计算"));
|
| 95 |
+
overviewCards.appendChild(createOverviewCard("活跃模型数", activeModels, "最近统计窗口内有调用记录的模型数量"));
|
| 96 |
+
overviewCards.appendChild(createOverviewCard("统计粒度", `${data.bucket_minutes} 分钟`, `已展示最近 ${data.models?.[0]?.points?.length || 0} 个时间片`));
|
| 97 |
+
dashboardUpdated.textContent = formatDateTime(data.generated_at);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function renderHealthCards(models) {
|
| 101 |
+
healthGrid.innerHTML = "";
|
| 102 |
+
if (!models || models.length === 0) {
|
| 103 |
+
dashboardEmpty.textContent = "当前未配置 MODEL_LIST,或尚无可展示的统计结果。";
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
dashboardEmpty.textContent = "";
|
| 107 |
+
|
| 108 |
+
models.forEach((model) => {
|
| 109 |
+
const latestMeta = rateMeta(model.latest_success_rate);
|
| 110 |
+
const latestRate = model.latest_success_rate === null || model.latest_success_rate === undefined ? "--" : `${model.latest_success_rate.toFixed(1)}%`;
|
| 111 |
+
const averageRate = model.average_success_rate === null || model.average_success_rate === undefined ? "--" : `${model.average_success_rate.toFixed(1)}%`;
|
| 112 |
+
|
| 113 |
+
const card = document.createElement("article");
|
| 114 |
+
card.className = `health-card ${STATUS_CLASSES[latestMeta.tone]}`;
|
| 115 |
+
card.innerHTML = `
|
| 116 |
+
<div class="card-head">
|
| 117 |
+
<div>
|
| 118 |
+
<h3>${model.model_id}</h3>
|
| 119 |
+
<p>${model.provider}</p>
|
| 120 |
+
</div>
|
| 121 |
+
<span class="status-pill ${STATUS_CLASSES[latestMeta.tone]}">${latestMeta.label}</span>
|
| 122 |
</div>
|
| 123 |
+
<div class="scoreboard">
|
| 124 |
+
<div class="score-item">
|
| 125 |
+
<span>最近 10 分钟</span>
|
| 126 |
+
<strong>${latestRate}</strong>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="score-item">
|
| 129 |
+
<span>近 1 小时均值</span>
|
| 130 |
+
<strong>${averageRate}</strong>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="score-item">
|
| 133 |
+
<span>累计调用次数</span>
|
| 134 |
+
<strong>${model.total_calls ?? 0}</strong>
|
| 135 |
+
</div>
|
| 136 |
</div>
|
| 137 |
+
`;
|
| 138 |
+
|
| 139 |
+
const timeline = document.createElement("div");
|
| 140 |
+
timeline.className = "timeline-strip";
|
| 141 |
+
(model.points || []).forEach((point) => {
|
| 142 |
+
const meta = rateMeta(point.success_rate);
|
| 143 |
+
const item = document.createElement("div");
|
| 144 |
+
item.className = `timeline-box ${STATUS_CLASSES[meta.tone]}`;
|
| 145 |
+
item.innerHTML = `<span>${point.label}</span><strong>${point.success_rate === null || point.success_rate === undefined ? "--" : `${point.success_rate.toFixed(0)}%`}</strong>`;
|
| 146 |
+
item.title = `${point.label} 成功 ${point.success_count}/${point.total_count}`;
|
| 147 |
+
timeline.appendChild(item);
|
| 148 |
+
});
|
| 149 |
+
card.appendChild(timeline);
|
| 150 |
+
healthGrid.appendChild(card);
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function renderCatalogSummary(data) {
|
| 155 |
+
catalogSummary.innerHTML = "";
|
| 156 |
+
catalogSummary.appendChild(createOverviewCard("官方模型总数", data.total_models ?? 0, "来自 NVIDIA 官方模型目录"));
|
| 157 |
+
catalogSummary.appendChild(createOverviewCard("提供商��量", data.providers?.length ?? 0, "按模型 ID 前缀自动归类"));
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function renderCatalogProviders(providers) {
|
| 161 |
+
providerGrid.innerHTML = "";
|
| 162 |
+
if (!providers || providers.length === 0) {
|
| 163 |
+
catalogEmpty.textContent = "暂时还没有拉取到官方模型目录,请稍后刷新。";
|
| 164 |
+
return;
|
| 165 |
}
|
| 166 |
+
catalogEmpty.textContent = "";
|
| 167 |
+
|
| 168 |
+
providers.forEach((group) => {
|
| 169 |
+
const card = document.createElement("article");
|
| 170 |
+
card.className = "provider-card";
|
| 171 |
+
card.innerHTML = `
|
| 172 |
+
<div class="provider-head">
|
| 173 |
+
<div>
|
| 174 |
+
<h3>${group.provider}</h3>
|
| 175 |
+
<p>${group.count} 个模型</p>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
`;
|
| 179 |
+
const chipWrap = document.createElement("div");
|
| 180 |
+
chipWrap.className = "model-chip-wrap";
|
| 181 |
+
(group.models || []).forEach((model) => {
|
| 182 |
+
const chip = document.createElement("span");
|
| 183 |
+
chip.className = "model-chip";
|
| 184 |
+
chip.textContent = model.id;
|
| 185 |
+
chipWrap.appendChild(chip);
|
| 186 |
+
});
|
| 187 |
+
card.appendChild(chipWrap);
|
| 188 |
+
providerGrid.appendChild(card);
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
|
| 192 |
+
async function loadDashboard() {
|
| 193 |
+
const response = await fetch("/api/dashboard", { headers: { Accept: "application/json" } });
|
| 194 |
+
if (!response.ok) throw new Error("仪表盘数据加载失败");
|
| 195 |
+
const payload = await response.json();
|
| 196 |
+
renderOverview(payload);
|
| 197 |
+
renderHealthCards(payload.models || []);
|
| 198 |
}
|
| 199 |
|
| 200 |
+
async function loadCatalog() {
|
| 201 |
+
const response = await fetch("/api/catalog", { headers: { Accept: "application/json" } });
|
| 202 |
+
if (!response.ok) throw new Error("模型目录加载失败");
|
| 203 |
+
const payload = await response.json();
|
| 204 |
+
catalogUpdated.textContent = formatDateTime(payload.synced_at || payload.generated_at);
|
| 205 |
+
renderCatalogSummary(payload);
|
| 206 |
+
renderCatalogProviders(payload.providers || []);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
async function refreshAll() {
|
| 210 |
try {
|
| 211 |
+
await Promise.all([loadDashboard(), loadCatalog()]);
|
| 212 |
+
} catch (error) {
|
| 213 |
+
dashboardEmpty.textContent = error.message;
|
| 214 |
+
catalogEmpty.textContent = error.message;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
}
|
| 217 |
|
| 218 |
+
refreshDashboardBtn?.addEventListener("click", refreshAll);
|
| 219 |
+
|
| 220 |
window.addEventListener("DOMContentLoaded", () => {
|
| 221 |
+
switchPage(0);
|
| 222 |
+
refreshAll();
|
| 223 |
+
window.setInterval(loadDashboard, 60 * 1000);
|
| 224 |
+
window.setInterval(loadCatalog, 5 * 60 * 1000);
|
| 225 |
});
|
static/style.css
CHANGED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
:root {
|
| 2 |
--bg: #050816;
|
| 3 |
--bg-deep: #02040b;
|
| 4 |
-
--panel: rgba(
|
| 5 |
-
--panel-strong: rgba(
|
| 6 |
-
--panel-soft: rgba(255, 255, 255, 0.
|
| 7 |
-
--
|
| 8 |
-
--muted: #94a7c7;
|
| 9 |
-
--muted-strong: #b4c6e4;
|
| 10 |
-
--line: rgba(255, 255, 255, 0.1);
|
| 11 |
--line-strong: rgba(255, 255, 255, 0.16);
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
--font-sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
| 19 |
--font-display: "Space Grotesk", "Noto Sans SC", sans-serif;
|
| 20 |
color-scheme: dark;
|
|
@@ -31,12 +32,12 @@ body {
|
|
| 31 |
|
| 32 |
body {
|
| 33 |
margin: 0;
|
| 34 |
-
background:
|
| 35 |
-
radial-gradient(circle at 8% 12%, rgba(53, 240, 161, 0.16), transparent 28%),
|
| 36 |
-
radial-gradient(circle at 88% 18%, rgba(67, 138, 255, 0.18), transparent 26%),
|
| 37 |
-
linear-gradient(180deg, #08101d 0%, var(--bg) 38%, var(--bg-deep) 100%);
|
| 38 |
-
color: var(--text);
|
| 39 |
font-family: var(--font-sans);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
overflow-x: hidden;
|
| 41 |
}
|
| 42 |
|
|
@@ -45,631 +46,470 @@ body::before {
|
|
| 45 |
position: fixed;
|
| 46 |
inset: 0;
|
| 47 |
background-image:
|
| 48 |
-
linear-gradient(rgba(255, 255, 255, 0.
|
| 49 |
-
linear-gradient(90deg, rgba(255, 255, 255, 0.
|
| 50 |
-
background-size:
|
| 51 |
-
opacity: 0.
|
| 52 |
pointer-events: none;
|
| 53 |
}
|
| 54 |
|
| 55 |
.ambient {
|
| 56 |
position: fixed;
|
| 57 |
-
width: 32rem;
|
| 58 |
-
height: 32rem;
|
| 59 |
border-radius: 999px;
|
| 60 |
-
filter: blur(
|
| 61 |
-
opacity: 0.38;
|
| 62 |
pointer-events: none;
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
.ambient-left {
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
left: -8rem;
|
| 68 |
-
background: rgba(
|
| 69 |
}
|
| 70 |
|
| 71 |
.ambient-right {
|
|
|
|
|
|
|
| 72 |
top: 8rem;
|
| 73 |
right: -10rem;
|
| 74 |
-
background: rgba(
|
| 75 |
}
|
| 76 |
|
| 77 |
-
.
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
-
.
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
| 85 |
margin: 0 auto;
|
| 86 |
-
padding:
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
.glass-panel,
|
| 93 |
-
.metric-card,
|
| 94 |
-
.health-record,
|
| 95 |
-
.empty-card {
|
| 96 |
-
position: relative;
|
| 97 |
-
border: 1px solid var(--line);
|
| 98 |
-
background: linear-gradient(180deg, rgba(15, 25, 48, 0.82), rgba(7, 12, 24, 0.9));
|
| 99 |
-
border-radius: 28px;
|
| 100 |
-
box-shadow: var(--shadow);
|
| 101 |
-
backdrop-filter: blur(18px);
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
.hero-panel,
|
| 105 |
-
.summary-panel,
|
| 106 |
-
.board-panel,
|
| 107 |
-
.glass-panel {
|
| 108 |
-
overflow: hidden;
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
.hero-panel::after,
|
| 112 |
-
.summary-panel::after,
|
| 113 |
-
.board-panel::after,
|
| 114 |
-
.glass-panel::after {
|
| 115 |
-
content: "";
|
| 116 |
-
position: absolute;
|
| 117 |
-
inset: 0;
|
| 118 |
-
background: linear-gradient(135deg, rgba(107, 255, 208, 0.08), transparent 34%, transparent 66%, rgba(123, 157, 255, 0.08));
|
| 119 |
-
pointer-events: none;
|
| 120 |
}
|
| 121 |
|
| 122 |
-
.
|
| 123 |
-
display:
|
| 124 |
-
|
| 125 |
-
gap:
|
| 126 |
-
padding: 34px;
|
| 127 |
}
|
| 128 |
|
|
|
|
| 129 |
.hero-copy h1,
|
| 130 |
-
.
|
| 131 |
-
.
|
| 132 |
-
.
|
| 133 |
-
.panel-headline h2,
|
| 134 |
-
.login-card h2,
|
| 135 |
-
.brand-block h1 {
|
| 136 |
font-family: var(--font-display);
|
| 137 |
}
|
| 138 |
|
|
|
|
| 139 |
.hero-badge,
|
| 140 |
.section-tag {
|
| 141 |
display: inline-flex;
|
| 142 |
align-items: center;
|
| 143 |
-
|
| 144 |
padding: 8px 14px;
|
| 145 |
border-radius: 999px;
|
| 146 |
-
border: 1px solid rgba(
|
| 147 |
-
background: rgba(
|
| 148 |
-
color:
|
| 149 |
font-size: 13px;
|
| 150 |
font-weight: 700;
|
| 151 |
letter-spacing: 0.08em;
|
| 152 |
}
|
| 153 |
|
| 154 |
-
.
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
line-height: 1.08;
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
.hero-copy p,
|
| 166 |
-
.hero-side p,
|
| 167 |
-
.board-note,
|
| 168 |
-
.status-text,
|
| 169 |
-
.metric-card p,
|
| 170 |
-
.brand-block p {
|
| 171 |
-
color: var(--muted);
|
| 172 |
-
line-height: 1.7;
|
| 173 |
}
|
| 174 |
|
| 175 |
-
.
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
| 181 |
}
|
| 182 |
|
| 183 |
-
.
|
|
|
|
|
|
|
|
|
|
| 184 |
color: var(--muted-strong);
|
| 185 |
-
letter-spacing: 0.16em;
|
| 186 |
-
text-transform: uppercase;
|
| 187 |
-
font-size: 12px;
|
| 188 |
}
|
| 189 |
|
| 190 |
-
.
|
| 191 |
-
|
| 192 |
-
|
| 193 |
font-weight: 700;
|
| 194 |
}
|
| 195 |
|
| 196 |
-
.
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
margin
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
-
.
|
| 203 |
-
.board-head,
|
| 204 |
-
.panel-headline,
|
| 205 |
-
.toolbar-row {
|
| 206 |
display: flex;
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
flex-wrap: wrap;
|
| 211 |
}
|
| 212 |
|
| 213 |
-
.
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
}
|
| 220 |
|
| 221 |
-
.
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
border: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
}
|
| 228 |
|
| 229 |
-
.
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
color: var(--muted);
|
| 232 |
-
|
| 233 |
-
margin-bottom: 8px;
|
| 234 |
}
|
| 235 |
|
| 236 |
-
.
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
|
| 241 |
-
.
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
display: grid;
|
| 244 |
-
grid-template-columns: repeat(
|
| 245 |
gap: 14px;
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
-
.
|
| 249 |
-
.metric-card {
|
| 250 |
padding: 18px 20px;
|
| 251 |
-
border-radius: 22px;
|
| 252 |
-
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
|
| 253 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 254 |
}
|
| 255 |
|
| 256 |
-
.
|
| 257 |
-
.metric-card h3 {
|
| 258 |
-
display: block;
|
| 259 |
color: var(--muted);
|
| 260 |
font-size: 13px;
|
| 261 |
-
margin-bottom: 10px;
|
| 262 |
}
|
| 263 |
|
| 264 |
-
.
|
| 265 |
-
|
|
|
|
| 266 |
font-family: var(--font-display);
|
| 267 |
-
font-size:
|
| 268 |
-
line-height: 1.2;
|
| 269 |
}
|
| 270 |
|
| 271 |
-
.
|
| 272 |
-
|
|
|
|
| 273 |
}
|
| 274 |
|
| 275 |
-
.
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
}
|
| 279 |
|
| 280 |
-
.
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
| 284 |
-
.
|
| 285 |
-
.
|
| 286 |
display: grid;
|
| 287 |
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 288 |
gap: 18px;
|
| 289 |
}
|
| 290 |
|
| 291 |
-
.
|
|
|
|
| 292 |
padding: 22px;
|
| 293 |
-
border-
|
| 294 |
-
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.025));
|
| 295 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 296 |
-
transition: transform 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
|
| 297 |
}
|
| 298 |
|
| 299 |
-
.
|
| 300 |
-
.
|
| 301 |
-
|
| 302 |
-
|
|
|
|
| 303 |
box-shadow: var(--glow);
|
| 304 |
}
|
| 305 |
|
| 306 |
-
.card-
|
| 307 |
-
.
|
| 308 |
-
.record-meta,
|
| 309 |
-
.health-meta,
|
| 310 |
-
.inline-actions {
|
| 311 |
display: flex;
|
| 312 |
-
gap: 10px;
|
| 313 |
-
flex-wrap: wrap;
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
.card-top {
|
| 317 |
justify-content: space-between;
|
|
|
|
| 318 |
align-items: flex-start;
|
| 319 |
}
|
| 320 |
|
| 321 |
-
.
|
|
|
|
|
|
|
| 322 |
font-size: 22px;
|
| 323 |
-
font-weight: 700;
|
| 324 |
-
line-height: 1.3;
|
| 325 |
}
|
| 326 |
|
| 327 |
-
.
|
| 328 |
-
.
|
| 329 |
-
|
| 330 |
-
color: var(--muted);
|
| 331 |
-
font-size: 13px;
|
| 332 |
}
|
| 333 |
|
| 334 |
-
.status-
|
| 335 |
-
.pill {
|
| 336 |
-
display: inline-flex;
|
| 337 |
-
align-items: center;
|
| 338 |
-
justify-content: center;
|
| 339 |
padding: 8px 12px;
|
| 340 |
-
min-height: 34px;
|
| 341 |
border-radius: 999px;
|
| 342 |
font-size: 12px;
|
| 343 |
font-weight: 700;
|
| 344 |
-
letter-spacing: 0.05em;
|
| 345 |
border: 1px solid transparent;
|
| 346 |
}
|
| 347 |
|
| 348 |
-
.
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
background: rgba(53, 240, 161, 0.12);
|
| 353 |
-
border-color: rgba(107, 255, 208, 0.28);
|
| 354 |
-
}
|
| 355 |
-
|
| 356 |
-
.status-chip.down,
|
| 357 |
-
.pill.down,
|
| 358 |
-
.danger-btn {
|
| 359 |
-
color: #ffb5c0;
|
| 360 |
-
background: rgba(255, 110, 131, 0.12);
|
| 361 |
-
border-color: rgba(255, 110, 131, 0.28);
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
.status-chip.degraded,
|
| 365 |
-
.status-chip.warn,
|
| 366 |
-
.pill.degraded,
|
| 367 |
-
.pill.warn {
|
| 368 |
-
color: #ffd18d;
|
| 369 |
-
background: rgba(255, 180, 79, 0.12);
|
| 370 |
-
border-color: rgba(255, 180, 79, 0.28);
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
.status-chip.unknown,
|
| 374 |
-
.pill.unknown,
|
| 375 |
-
.pill.idle {
|
| 376 |
-
color: #c7d4ea;
|
| 377 |
-
background: rgba(255, 255, 255, 0.06);
|
| 378 |
-
border-color: rgba(255, 255, 255, 0.1);
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
.metric-row {
|
| 382 |
margin-top: 18px;
|
| 383 |
}
|
| 384 |
|
| 385 |
-
.
|
| 386 |
-
flex: 1 1 140px;
|
| 387 |
padding: 14px 16px;
|
| 388 |
border-radius: 18px;
|
| 389 |
-
background: rgba(255, 255, 255, 0.
|
| 390 |
-
border: 1px solid rgba(255, 255, 255, 0.
|
| 391 |
}
|
| 392 |
|
| 393 |
-
.
|
| 394 |
display: block;
|
| 395 |
color: var(--muted);
|
| 396 |
font-size: 12px;
|
| 397 |
-
margin-bottom: 8px;
|
| 398 |
}
|
| 399 |
|
| 400 |
-
.
|
|
|
|
|
|
|
| 401 |
font-family: var(--font-display);
|
| 402 |
-
font-size:
|
| 403 |
}
|
| 404 |
|
| 405 |
-
.timeline {
|
| 406 |
-
display:
|
|
|
|
| 407 |
gap: 10px;
|
| 408 |
-
flex-wrap: wrap;
|
| 409 |
margin-top: 18px;
|
| 410 |
}
|
| 411 |
|
| 412 |
-
.timeline-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
border-radius: 14px;
|
| 416 |
-
display: inline-flex;
|
| 417 |
-
align-items: center;
|
| 418 |
-
justify-content: center;
|
| 419 |
-
font-family: var(--font-display);
|
| 420 |
-
font-size: 13px;
|
| 421 |
-
font-weight: 700;
|
| 422 |
-
background: rgba(255, 255, 255, 0.04);
|
| 423 |
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
|
| 424 |
}
|
| 425 |
|
| 426 |
-
.timeline-
|
| 427 |
-
|
| 428 |
-
color: #04110d;
|
| 429 |
-
border-color: transparent;
|
| 430 |
-
}
|
| 431 |
-
|
| 432 |
-
.timeline-item.warn {
|
| 433 |
-
background: linear-gradient(135deg, rgba(255, 180, 79, 0.9), rgba(255, 218, 131, 0.85));
|
| 434 |
-
color: #1b1203;
|
| 435 |
-
border-color: transparent;
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
.timeline-item.down {
|
| 439 |
-
background: linear-gradient(135deg, rgba(255, 110, 131, 0.95), rgba(255, 171, 128, 0.84));
|
| 440 |
-
color: #19080d;
|
| 441 |
-
border-color: transparent;
|
| 442 |
-
}
|
| 443 |
-
|
| 444 |
-
.timeline-item.idle {
|
| 445 |
color: var(--muted);
|
|
|
|
| 446 |
}
|
| 447 |
|
| 448 |
-
.
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
color: var(--muted);
|
| 454 |
-
background: rgba(255, 255, 255, 0.04);
|
| 455 |
-
border: 1px dashed rgba(255, 255, 255, 0.12);
|
| 456 |
}
|
| 457 |
|
| 458 |
-
.
|
|
|
|
|
|
|
|
|
|
| 459 |
margin-top: 18px;
|
| 460 |
-
color: #ffb2bf;
|
| 461 |
-
}
|
| 462 |
-
|
| 463 |
-
button,
|
| 464 |
-
.secondary-btn {
|
| 465 |
-
border: none;
|
| 466 |
-
cursor: pointer;
|
| 467 |
-
font: inherit;
|
| 468 |
-
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease;
|
| 469 |
}
|
| 470 |
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
}
|
| 475 |
-
|
| 476 |
-
button {
|
| 477 |
-
padding: 12px 18px;
|
| 478 |
-
border-radius: 16px;
|
| 479 |
-
background: linear-gradient(135deg, #2be89a, #64ffd6);
|
| 480 |
-
color: #03110d;
|
| 481 |
-
font-weight: 800;
|
| 482 |
-
box-shadow: 0 14px 30px rgba(53, 240, 161, 0.18);
|
| 483 |
-
}
|
| 484 |
-
|
| 485 |
-
.secondary-btn {
|
| 486 |
padding: 10px 14px;
|
| 487 |
-
border-radius:
|
| 488 |
background: rgba(255, 255, 255, 0.05);
|
| 489 |
-
border: 1px solid rgba(255, 255, 255, 0.14);
|
| 490 |
-
color: var(--text);
|
| 491 |
-
}
|
| 492 |
-
|
| 493 |
-
.admin-shell {
|
| 494 |
-
display: grid;
|
| 495 |
-
grid-template-columns: 320px minmax(0, 1fr);
|
| 496 |
-
min-height: 100vh;
|
| 497 |
-
}
|
| 498 |
-
|
| 499 |
-
.admin-sidebar {
|
| 500 |
-
padding: 28px 22px;
|
| 501 |
-
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
| 502 |
-
background: rgba(4, 8, 17, 0.88);
|
| 503 |
-
backdrop-filter: blur(16px);
|
| 504 |
-
}
|
| 505 |
-
|
| 506 |
-
.brand-block {
|
| 507 |
-
padding: 22px;
|
| 508 |
-
border-radius: 24px;
|
| 509 |
-
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03));
|
| 510 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 511 |
-
margin-bottom: 24px;
|
| 512 |
-
}
|
| 513 |
-
|
| 514 |
-
.brand-block h1 {
|
| 515 |
-
font-size: 32px;
|
| 516 |
-
}
|
| 517 |
-
|
| 518 |
-
.admin-sidebar h3 {
|
| 519 |
-
margin: 0 0 12px;
|
| 520 |
-
color: var(--muted);
|
| 521 |
-
font-size: 13px;
|
| 522 |
-
letter-spacing: 0.18em;
|
| 523 |
-
}
|
| 524 |
-
|
| 525 |
-
.admin-sidebar .sidebar-btn {
|
| 526 |
-
width: 100%;
|
| 527 |
-
margin-bottom: 10px;
|
| 528 |
-
padding: 14px 16px;
|
| 529 |
-
text-align: left;
|
| 530 |
-
color: var(--text);
|
| 531 |
-
background: rgba(255, 255, 255, 0.04);
|
| 532 |
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 533 |
-
|
| 534 |
-
}
|
| 535 |
-
|
| 536 |
-
.admin-sidebar .sidebar-btn.active {
|
| 537 |
-
color: var(--green-strong);
|
| 538 |
-
background: rgba(53, 240, 161, 0.1);
|
| 539 |
-
border-color: rgba(107, 255, 208, 0.22);
|
| 540 |
-
box-shadow: var(--glow);
|
| 541 |
-
}
|
| 542 |
-
|
| 543 |
-
.admin-content {
|
| 544 |
-
padding: 30px;
|
| 545 |
-
}
|
| 546 |
-
|
| 547 |
-
.glass-panel {
|
| 548 |
-
padding: 28px;
|
| 549 |
-
margin-bottom: 22px;
|
| 550 |
-
}
|
| 551 |
-
|
| 552 |
-
.sub-panel {
|
| 553 |
-
margin-top: 20px;
|
| 554 |
-
}
|
| 555 |
-
|
| 556 |
-
.panel-headline.compact h3 {
|
| 557 |
-
margin-top: 8px;
|
| 558 |
-
}
|
| 559 |
-
|
| 560 |
-
.table {
|
| 561 |
-
width: 100%;
|
| 562 |
-
border-collapse: collapse;
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
.table thead th {
|
| 566 |
-
padding: 14px 12px;
|
| 567 |
-
text-align: left;
|
| 568 |
-
color: var(--muted);
|
| 569 |
font-size: 13px;
|
| 570 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
-
.table tbody td {
|
| 574 |
-
padding: 16px 12px;
|
| 575 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 576 |
-
vertical-align: top;
|
| 577 |
}
|
| 578 |
|
| 579 |
-
.
|
| 580 |
-
|
| 581 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 582 |
-
gap: 14px;
|
| 583 |
-
}
|
| 584 |
-
|
| 585 |
-
.compact-grid {
|
| 586 |
-
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 587 |
-
}
|
| 588 |
-
|
| 589 |
-
.form-grid input,
|
| 590 |
-
.form-grid textarea,
|
| 591 |
-
.login-card input {
|
| 592 |
-
width: 100%;
|
| 593 |
-
padding: 14px 16px;
|
| 594 |
-
border-radius: 16px;
|
| 595 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 596 |
-
background: rgba(255, 255, 255, 0.045);
|
| 597 |
-
color: var(--text);
|
| 598 |
-
font: inherit;
|
| 599 |
-
outline: none;
|
| 600 |
-
}
|
| 601 |
-
|
| 602 |
-
.form-grid input:focus,
|
| 603 |
-
.form-grid textarea:focus,
|
| 604 |
-
.login-card input:focus {
|
| 605 |
-
border-color: rgba(107, 255, 208, 0.32);
|
| 606 |
-
box-shadow: 0 0 0 4px rgba(53, 240, 161, 0.08);
|
| 607 |
}
|
| 608 |
|
| 609 |
-
.
|
| 610 |
-
|
| 611 |
-
resize: vertical;
|
| 612 |
-
grid-column: 1 / -1;
|
| 613 |
}
|
| 614 |
|
| 615 |
-
.
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
color:
|
|
|
|
| 620 |
}
|
| 621 |
|
| 622 |
-
.
|
| 623 |
-
|
| 624 |
-
height: 18px;
|
| 625 |
}
|
| 626 |
|
| 627 |
-
.
|
| 628 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
}
|
| 630 |
|
| 631 |
-
.
|
| 632 |
-
|
| 633 |
-
padding: 20px;
|
| 634 |
-
border-radius: 22px;
|
| 635 |
}
|
| 636 |
|
| 637 |
-
.
|
| 638 |
-
.
|
| 639 |
-
|
|
|
|
|
|
|
|
|
|
| 640 |
}
|
| 641 |
|
| 642 |
-
.
|
| 643 |
-
|
| 644 |
-
color: var(--muted);
|
| 645 |
-
font-size: 13px;
|
| 646 |
}
|
| 647 |
|
| 648 |
-
.
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
background: rgba(3, 6, 14, 0.76);
|
| 655 |
-
backdrop-filter: blur(12px);
|
| 656 |
-
z-index: 20;
|
| 657 |
}
|
| 658 |
|
| 659 |
-
.
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
border:
|
| 665 |
-
box-shadow: var(--shadow);
|
| 666 |
-
}
|
| 667 |
-
|
| 668 |
-
.login-card label {
|
| 669 |
-
display: block;
|
| 670 |
-
margin: 16px 0 10px;
|
| 671 |
-
color: var(--muted-strong);
|
| 672 |
-
font-size: 13px;
|
| 673 |
}
|
| 674 |
|
| 675 |
.hidden {
|
|
@@ -677,99 +517,53 @@ button {
|
|
| 677 |
}
|
| 678 |
|
| 679 |
@media (max-width: 980px) {
|
| 680 |
-
.
|
| 681 |
-
|
|
|
|
| 682 |
}
|
| 683 |
|
| 684 |
-
.
|
|
|
|
| 685 |
grid-template-columns: 1fr;
|
| 686 |
}
|
| 687 |
|
| 688 |
-
.
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
}
|
| 693 |
}
|
| 694 |
|
| 695 |
@media (max-width: 720px) {
|
| 696 |
-
.
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
}
|
| 700 |
-
|
| 701 |
-
.hero-panel,
|
| 702 |
-
.summary-panel,
|
| 703 |
-
.board-panel,
|
| 704 |
-
.glass-panel,
|
| 705 |
-
.admin-content {
|
| 706 |
-
padding: 20px;
|
| 707 |
}
|
| 708 |
|
| 709 |
-
.
|
| 710 |
-
|
|
|
|
| 711 |
}
|
| 712 |
|
| 713 |
-
.
|
| 714 |
-
|
| 715 |
}
|
| 716 |
|
| 717 |
-
.
|
| 718 |
-
|
| 719 |
-
.section-grid {
|
| 720 |
-
grid-template-columns: 1fr;
|
| 721 |
}
|
| 722 |
-
}
|
| 723 |
-
.panel-actions {
|
| 724 |
-
align-items: center;
|
| 725 |
-
}
|
| 726 |
-
|
| 727 |
-
.settings-grid {
|
| 728 |
-
align-items: start;
|
| 729 |
-
}
|
| 730 |
-
|
| 731 |
-
.field-span-full {
|
| 732 |
-
grid-column: 1 / -1;
|
| 733 |
-
}
|
| 734 |
-
|
| 735 |
-
.settings-grid .checkbox-row {
|
| 736 |
-
min-height: 58px;
|
| 737 |
-
padding: 14px 16px;
|
| 738 |
-
border-radius: 16px;
|
| 739 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 740 |
-
background: rgba(255, 255, 255, 0.045);
|
| 741 |
-
}
|
| 742 |
-
|
| 743 |
-
.settings-grid .checkbox-row input {
|
| 744 |
-
width: 18px;
|
| 745 |
-
min-width: 18px;
|
| 746 |
-
height: 18px;
|
| 747 |
-
padding: 0;
|
| 748 |
-
margin: 0;
|
| 749 |
-
border-radius: 6px;
|
| 750 |
-
background: rgba(255, 255, 255, 0.02);
|
| 751 |
-
box-shadow: none;
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
.settings-actions {
|
| 755 |
-
justify-content: flex-start;
|
| 756 |
-
align-items: center;
|
| 757 |
-
}
|
| 758 |
|
| 759 |
-
.
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
}
|
| 763 |
-
|
| 764 |
-
@media (max-width: 720px) {
|
| 765 |
-
.settings-actions {
|
| 766 |
-
flex-direction: column;
|
| 767 |
-
align-items: stretch;
|
| 768 |
}
|
| 769 |
|
| 770 |
-
.
|
| 771 |
-
.
|
| 772 |
-
.
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
| 774 |
}
|
| 775 |
}
|
|
|
|
| 1 |
:root {
|
| 2 |
--bg: #050816;
|
| 3 |
--bg-deep: #02040b;
|
| 4 |
+
--panel: rgba(10, 15, 28, 0.72);
|
| 5 |
+
--panel-strong: rgba(14, 20, 36, 0.92);
|
| 6 |
+
--panel-soft: rgba(255, 255, 255, 0.05);
|
| 7 |
+
--line: rgba(255, 255, 255, 0.09);
|
|
|
|
|
|
|
|
|
|
| 8 |
--line-strong: rgba(255, 255, 255, 0.16);
|
| 9 |
+
--text: #f4f7ff;
|
| 10 |
+
--muted: #91a3c4;
|
| 11 |
+
--muted-strong: #c7d4ea;
|
| 12 |
+
--green: #38f3a5;
|
| 13 |
+
--yellow: #ffd24d;
|
| 14 |
+
--orange: #ff9b43;
|
| 15 |
+
--red: #ff637f;
|
| 16 |
+
--idle: #5e6d88;
|
| 17 |
+
--shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
|
| 18 |
+
--glow: 0 0 0 1px rgba(56, 243, 165, 0.12), 0 24px 70px rgba(56, 243, 165, 0.12);
|
| 19 |
--font-sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
| 20 |
--font-display: "Space Grotesk", "Noto Sans SC", sans-serif;
|
| 21 |
color-scheme: dark;
|
|
|
|
| 32 |
|
| 33 |
body {
|
| 34 |
margin: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
font-family: var(--font-sans);
|
| 36 |
+
color: var(--text);
|
| 37 |
+
background:
|
| 38 |
+
radial-gradient(circle at 12% 16%, rgba(56, 243, 165, 0.18), transparent 24%),
|
| 39 |
+
radial-gradient(circle at 84% 18%, rgba(79, 125, 255, 0.18), transparent 26%),
|
| 40 |
+
linear-gradient(180deg, #08111f 0%, var(--bg) 42%, var(--bg-deep) 100%);
|
| 41 |
overflow-x: hidden;
|
| 42 |
}
|
| 43 |
|
|
|
|
| 46 |
position: fixed;
|
| 47 |
inset: 0;
|
| 48 |
background-image:
|
| 49 |
+
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
| 50 |
+
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
| 51 |
+
background-size: 36px 36px;
|
| 52 |
+
opacity: 0.08;
|
| 53 |
pointer-events: none;
|
| 54 |
}
|
| 55 |
|
| 56 |
.ambient {
|
| 57 |
position: fixed;
|
|
|
|
|
|
|
| 58 |
border-radius: 999px;
|
| 59 |
+
filter: blur(100px);
|
|
|
|
| 60 |
pointer-events: none;
|
| 61 |
+
opacity: 0.36;
|
| 62 |
}
|
| 63 |
|
| 64 |
.ambient-left {
|
| 65 |
+
width: 26rem;
|
| 66 |
+
height: 26rem;
|
| 67 |
+
top: -8rem;
|
| 68 |
left: -8rem;
|
| 69 |
+
background: rgba(56, 243, 165, 0.36);
|
| 70 |
}
|
| 71 |
|
| 72 |
.ambient-right {
|
| 73 |
+
width: 32rem;
|
| 74 |
+
height: 32rem;
|
| 75 |
top: 8rem;
|
| 76 |
right: -10rem;
|
| 77 |
+
background: rgba(110, 125, 255, 0.28);
|
| 78 |
}
|
| 79 |
|
| 80 |
+
.ambient-bottom {
|
| 81 |
+
width: 28rem;
|
| 82 |
+
height: 28rem;
|
| 83 |
+
bottom: -10rem;
|
| 84 |
+
right: 18%;
|
| 85 |
+
background: rgba(255, 171, 72, 0.18);
|
| 86 |
}
|
| 87 |
|
| 88 |
+
.topbar {
|
| 89 |
+
position: sticky;
|
| 90 |
+
top: 0;
|
| 91 |
+
z-index: 30;
|
| 92 |
+
width: min(1360px, calc(100vw - 32px));
|
| 93 |
margin: 0 auto;
|
| 94 |
+
padding: 22px 0 12px;
|
| 95 |
+
display: flex;
|
| 96 |
+
justify-content: space-between;
|
| 97 |
+
align-items: center;
|
| 98 |
+
gap: 18px;
|
| 99 |
+
backdrop-filter: blur(10px);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
}
|
| 101 |
|
| 102 |
+
.brand-mark {
|
| 103 |
+
display: flex;
|
| 104 |
+
flex-direction: column;
|
| 105 |
+
gap: 6px;
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
+
.brand-mark strong,
|
| 109 |
.hero-copy h1,
|
| 110 |
+
.section-head h2,
|
| 111 |
+
.provider-head h3,
|
| 112 |
+
.health-card h3 {
|
|
|
|
|
|
|
|
|
|
| 113 |
font-family: var(--font-display);
|
| 114 |
}
|
| 115 |
|
| 116 |
+
.brand-badge,
|
| 117 |
.hero-badge,
|
| 118 |
.section-tag {
|
| 119 |
display: inline-flex;
|
| 120 |
align-items: center;
|
| 121 |
+
width: fit-content;
|
| 122 |
padding: 8px 14px;
|
| 123 |
border-radius: 999px;
|
| 124 |
+
border: 1px solid rgba(56, 243, 165, 0.26);
|
| 125 |
+
background: rgba(56, 243, 165, 0.1);
|
| 126 |
+
color: #8affd0;
|
| 127 |
font-size: 13px;
|
| 128 |
font-weight: 700;
|
| 129 |
letter-spacing: 0.08em;
|
| 130 |
}
|
| 131 |
|
| 132 |
+
.page-nav {
|
| 133 |
+
display: inline-flex;
|
| 134 |
+
gap: 10px;
|
| 135 |
+
padding: 8px;
|
| 136 |
+
border-radius: 999px;
|
| 137 |
+
background: rgba(255, 255, 255, 0.05);
|
| 138 |
+
border: 1px solid var(--line);
|
| 139 |
+
box-shadow: var(--shadow);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
+
.page-nav-btn,
|
| 143 |
+
.primary-btn,
|
| 144 |
+
.ghost-btn {
|
| 145 |
+
border: none;
|
| 146 |
+
cursor: pointer;
|
| 147 |
+
font: inherit;
|
| 148 |
+
transition: transform 0.22s ease, opacity 0.22s ease, background 0.22s ease, border-color 0.22s ease;
|
| 149 |
}
|
| 150 |
|
| 151 |
+
.page-nav-btn {
|
| 152 |
+
padding: 10px 16px;
|
| 153 |
+
border-radius: 999px;
|
| 154 |
+
background: transparent;
|
| 155 |
color: var(--muted-strong);
|
|
|
|
|
|
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
+
.page-nav-btn.active {
|
| 159 |
+
color: #04110d;
|
| 160 |
+
background: linear-gradient(135deg, var(--green), #7affd8);
|
| 161 |
font-weight: 700;
|
| 162 |
}
|
| 163 |
|
| 164 |
+
.showcase-shell {
|
| 165 |
+
position: relative;
|
| 166 |
+
width: min(1360px, calc(100vw - 32px));
|
| 167 |
+
margin: 0 auto 42px;
|
| 168 |
+
overflow: hidden;
|
| 169 |
}
|
| 170 |
|
| 171 |
+
.page-track {
|
|
|
|
|
|
|
|
|
|
| 172 |
display: flex;
|
| 173 |
+
width: 200%;
|
| 174 |
+
transform: translate3d(0, 0, 0);
|
| 175 |
+
transition: transform 0.82s cubic-bezier(0.22, 1, 0.36, 1);
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
+
.page-section {
|
| 179 |
+
width: 50%;
|
| 180 |
+
padding-right: 18px;
|
| 181 |
+
opacity: 0.74;
|
| 182 |
+
transform: scale(0.985);
|
| 183 |
+
transition: opacity 0.55s ease, transform 0.55s ease;
|
| 184 |
}
|
| 185 |
|
| 186 |
+
.page-section.active {
|
| 187 |
+
opacity: 1;
|
| 188 |
+
transform: scale(1);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.hero-stage,
|
| 192 |
+
.panel-shell,
|
| 193 |
+
.overview-card,
|
| 194 |
+
.health-card,
|
| 195 |
+
.provider-card {
|
| 196 |
+
position: relative;
|
| 197 |
+
border-radius: 28px;
|
| 198 |
border: 1px solid var(--line);
|
| 199 |
+
background: linear-gradient(180deg, rgba(17, 25, 44, 0.84), rgba(8, 12, 22, 0.95));
|
| 200 |
+
box-shadow: var(--shadow);
|
| 201 |
+
backdrop-filter: blur(18px);
|
| 202 |
+
overflow: hidden;
|
| 203 |
}
|
| 204 |
|
| 205 |
+
.hero-stage::after,
|
| 206 |
+
.panel-shell::after,
|
| 207 |
+
.health-card::after,
|
| 208 |
+
.provider-card::after,
|
| 209 |
+
.overview-card::after {
|
| 210 |
+
content: "";
|
| 211 |
+
position: absolute;
|
| 212 |
+
inset: 0;
|
| 213 |
+
background: linear-gradient(140deg, rgba(138, 255, 208, 0.08), transparent 32%, transparent 68%, rgba(116, 126, 255, 0.08));
|
| 214 |
+
pointer-events: none;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.hero-stage {
|
| 218 |
+
display: grid;
|
| 219 |
+
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.9fr);
|
| 220 |
+
gap: 24px;
|
| 221 |
+
padding: 34px;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.catalog-stage {
|
| 225 |
+
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.hero-copy h1 {
|
| 229 |
+
margin: 16px 0 12px;
|
| 230 |
+
font-size: clamp(34px, 3vw, 58px);
|
| 231 |
+
line-height: 1.06;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.hero-copy p,
|
| 235 |
+
.section-head p,
|
| 236 |
+
.brand-mark strong,
|
| 237 |
+
.provider-head p,
|
| 238 |
+
.panel-hint,
|
| 239 |
+
.health-card p,
|
| 240 |
+
.overview-card p {
|
| 241 |
color: var(--muted);
|
| 242 |
+
line-height: 1.75;
|
|
|
|
| 243 |
}
|
| 244 |
|
| 245 |
+
.hero-actions {
|
| 246 |
+
margin-top: 28px;
|
| 247 |
+
display: flex;
|
| 248 |
+
gap: 14px;
|
| 249 |
+
flex-wrap: wrap;
|
| 250 |
}
|
| 251 |
|
| 252 |
+
.primary-btn,
|
| 253 |
+
.ghost-btn {
|
| 254 |
+
padding: 14px 20px;
|
| 255 |
+
border-radius: 18px;
|
| 256 |
+
font-weight: 700;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.primary-btn {
|
| 260 |
+
background: linear-gradient(135deg, var(--green), #7affd8);
|
| 261 |
+
color: #04110d;
|
| 262 |
+
box-shadow: var(--glow);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.ghost-btn {
|
| 266 |
+
background: rgba(255, 255, 255, 0.05);
|
| 267 |
+
color: var(--text);
|
| 268 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.hero-metrics,
|
| 272 |
+
.catalog-summary {
|
| 273 |
display: grid;
|
| 274 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 275 |
gap: 14px;
|
| 276 |
+
align-content: start;
|
| 277 |
}
|
| 278 |
|
| 279 |
+
.overview-card {
|
|
|
|
| 280 |
padding: 18px 20px;
|
|
|
|
|
|
|
|
|
|
| 281 |
}
|
| 282 |
|
| 283 |
+
.overview-card span {
|
|
|
|
|
|
|
| 284 |
color: var(--muted);
|
| 285 |
font-size: 13px;
|
|
|
|
| 286 |
}
|
| 287 |
|
| 288 |
+
.overview-card strong {
|
| 289 |
+
display: block;
|
| 290 |
+
margin: 10px 0 8px;
|
| 291 |
font-family: var(--font-display);
|
| 292 |
+
font-size: 30px;
|
|
|
|
| 293 |
}
|
| 294 |
|
| 295 |
+
.panel-shell {
|
| 296 |
+
margin-top: 22px;
|
| 297 |
+
padding: 28px;
|
| 298 |
}
|
| 299 |
|
| 300 |
+
.section-head {
|
| 301 |
+
display: flex;
|
| 302 |
+
justify-content: space-between;
|
| 303 |
+
align-items: flex-start;
|
| 304 |
+
gap: 18px;
|
| 305 |
+
flex-wrap: wrap;
|
| 306 |
+
margin-bottom: 20px;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.section-head h2 {
|
| 310 |
+
margin: 12px 0 0;
|
| 311 |
+
font-size: clamp(26px, 2vw, 38px);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.section-meta {
|
| 315 |
+
min-width: 220px;
|
| 316 |
+
padding: 16px 18px;
|
| 317 |
+
border-radius: 22px;
|
| 318 |
+
background: rgba(255, 255, 255, 0.05);
|
| 319 |
+
border: 1px solid var(--line);
|
| 320 |
}
|
| 321 |
|
| 322 |
+
.section-meta span {
|
| 323 |
+
display: block;
|
| 324 |
+
color: var(--muted);
|
| 325 |
+
font-size: 13px;
|
| 326 |
+
margin-bottom: 8px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.section-meta strong {
|
| 330 |
+
font-family: var(--font-display);
|
| 331 |
+
font-size: 20px;
|
| 332 |
}
|
| 333 |
|
| 334 |
+
.health-grid,
|
| 335 |
+
.provider-grid {
|
| 336 |
display: grid;
|
| 337 |
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 338 |
gap: 18px;
|
| 339 |
}
|
| 340 |
|
| 341 |
+
.health-card,
|
| 342 |
+
.provider-card {
|
| 343 |
padding: 22px;
|
| 344 |
+
transition: transform 0.26s ease, border-color 0.26s ease, box-shadow 0.26s ease;
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
+
.health-card:hover,
|
| 348 |
+
.provider-card:hover,
|
| 349 |
+
.overview-card:hover {
|
| 350 |
+
transform: translateY(-4px);
|
| 351 |
+
border-color: rgba(255, 255, 255, 0.16);
|
| 352 |
box-shadow: var(--glow);
|
| 353 |
}
|
| 354 |
|
| 355 |
+
.card-head,
|
| 356 |
+
.provider-head {
|
|
|
|
|
|
|
|
|
|
| 357 |
display: flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
justify-content: space-between;
|
| 359 |
+
gap: 12px;
|
| 360 |
align-items: flex-start;
|
| 361 |
}
|
| 362 |
|
| 363 |
+
.health-card h3,
|
| 364 |
+
.provider-head h3 {
|
| 365 |
+
margin: 0;
|
| 366 |
font-size: 22px;
|
|
|
|
|
|
|
| 367 |
}
|
| 368 |
|
| 369 |
+
.health-card p,
|
| 370 |
+
.provider-head p {
|
| 371 |
+
margin: 8px 0 0;
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
|
| 374 |
+
.status-pill {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
padding: 8px 12px;
|
|
|
|
| 376 |
border-radius: 999px;
|
| 377 |
font-size: 12px;
|
| 378 |
font-weight: 700;
|
|
|
|
| 379 |
border: 1px solid transparent;
|
| 380 |
}
|
| 381 |
|
| 382 |
+
.scoreboard {
|
| 383 |
+
display: grid;
|
| 384 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 385 |
+
gap: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
margin-top: 18px;
|
| 387 |
}
|
| 388 |
|
| 389 |
+
.score-item {
|
|
|
|
| 390 |
padding: 14px 16px;
|
| 391 |
border-radius: 18px;
|
| 392 |
+
background: rgba(255, 255, 255, 0.045);
|
| 393 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 394 |
}
|
| 395 |
|
| 396 |
+
.score-item span {
|
| 397 |
display: block;
|
| 398 |
color: var(--muted);
|
| 399 |
font-size: 12px;
|
|
|
|
| 400 |
}
|
| 401 |
|
| 402 |
+
.score-item strong {
|
| 403 |
+
display: block;
|
| 404 |
+
margin-top: 10px;
|
| 405 |
font-family: var(--font-display);
|
| 406 |
+
font-size: 18px;
|
| 407 |
}
|
| 408 |
|
| 409 |
+
.timeline-strip {
|
| 410 |
+
display: grid;
|
| 411 |
+
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
|
| 412 |
gap: 10px;
|
|
|
|
| 413 |
margin-top: 18px;
|
| 414 |
}
|
| 415 |
|
| 416 |
+
.timeline-box {
|
| 417 |
+
padding: 12px 10px;
|
| 418 |
+
border-radius: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 420 |
+
text-align: center;
|
| 421 |
+
background: rgba(255, 255, 255, 0.04);
|
| 422 |
}
|
| 423 |
|
| 424 |
+
.timeline-box span {
|
| 425 |
+
display: block;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
color: var(--muted);
|
| 427 |
+
font-size: 12px;
|
| 428 |
}
|
| 429 |
|
| 430 |
+
.timeline-box strong {
|
| 431 |
+
display: block;
|
| 432 |
+
margin-top: 8px;
|
| 433 |
+
font-family: var(--font-display);
|
| 434 |
+
font-size: 18px;
|
|
|
|
|
|
|
|
|
|
| 435 |
}
|
| 436 |
|
| 437 |
+
.model-chip-wrap {
|
| 438 |
+
display: flex;
|
| 439 |
+
flex-wrap: wrap;
|
| 440 |
+
gap: 10px;
|
| 441 |
margin-top: 18px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
}
|
| 443 |
|
| 444 |
+
.model-chip {
|
| 445 |
+
display: inline-flex;
|
| 446 |
+
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
padding: 10px 14px;
|
| 448 |
+
border-radius: 999px;
|
| 449 |
background: rgba(255, 255, 255, 0.05);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 451 |
+
color: var(--muted-strong);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
font-size: 13px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
}
|
| 454 |
|
| 455 |
+
.panel-hint {
|
| 456 |
+
margin-top: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
}
|
| 458 |
|
| 459 |
+
.is-green {
|
| 460 |
+
border-color: rgba(56, 243, 165, 0.26);
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
|
| 463 |
+
.is-green .status-pill,
|
| 464 |
+
.is-green.timeline-box,
|
| 465 |
+
.timeline-box.is-green {
|
| 466 |
+
background: rgba(56, 243, 165, 0.16);
|
| 467 |
+
color: #8affd0;
|
| 468 |
+
border-color: rgba(56, 243, 165, 0.3);
|
| 469 |
}
|
| 470 |
|
| 471 |
+
.is-yellow {
|
| 472 |
+
border-color: rgba(255, 210, 77, 0.22);
|
|
|
|
| 473 |
}
|
| 474 |
|
| 475 |
+
.is-yellow .status-pill,
|
| 476 |
+
.is-yellow.timeline-box,
|
| 477 |
+
.timeline-box.is-yellow {
|
| 478 |
+
background: rgba(255, 210, 77, 0.14);
|
| 479 |
+
color: #ffe59b;
|
| 480 |
+
border-color: rgba(255, 210, 77, 0.28);
|
| 481 |
}
|
| 482 |
|
| 483 |
+
.is-orange {
|
| 484 |
+
border-color: rgba(255, 155, 67, 0.26);
|
|
|
|
|
|
|
| 485 |
}
|
| 486 |
|
| 487 |
+
.is-orange .status-pill,
|
| 488 |
+
.is-orange.timeline-box,
|
| 489 |
+
.timeline-box.is-orange {
|
| 490 |
+
background: rgba(255, 155, 67, 0.15);
|
| 491 |
+
color: #ffc48a;
|
| 492 |
+
border-color: rgba(255, 155, 67, 0.3);
|
| 493 |
}
|
| 494 |
|
| 495 |
+
.is-red {
|
| 496 |
+
border-color: rgba(255, 99, 127, 0.28);
|
|
|
|
|
|
|
| 497 |
}
|
| 498 |
|
| 499 |
+
.is-red .status-pill,
|
| 500 |
+
.is-red.timeline-box,
|
| 501 |
+
.timeline-box.is-red {
|
| 502 |
+
background: rgba(255, 99, 127, 0.16);
|
| 503 |
+
color: #ffbbca;
|
| 504 |
+
border-color: rgba(255, 99, 127, 0.32);
|
|
|
|
|
|
|
|
|
|
| 505 |
}
|
| 506 |
|
| 507 |
+
.is-idle .status-pill,
|
| 508 |
+
.is-idle.timeline-box,
|
| 509 |
+
.timeline-box.is-idle {
|
| 510 |
+
background: rgba(94, 109, 136, 0.15);
|
| 511 |
+
color: #c5d0df;
|
| 512 |
+
border-color: rgba(94, 109, 136, 0.26);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
}
|
| 514 |
|
| 515 |
.hidden {
|
|
|
|
| 517 |
}
|
| 518 |
|
| 519 |
@media (max-width: 980px) {
|
| 520 |
+
.topbar,
|
| 521 |
+
.showcase-shell {
|
| 522 |
+
width: min(100vw - 24px, 1360px);
|
| 523 |
}
|
| 524 |
|
| 525 |
+
.hero-stage,
|
| 526 |
+
.catalog-stage {
|
| 527 |
grid-template-columns: 1fr;
|
| 528 |
}
|
| 529 |
|
| 530 |
+
.hero-metrics,
|
| 531 |
+
.catalog-summary,
|
| 532 |
+
.scoreboard {
|
| 533 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 534 |
}
|
| 535 |
}
|
| 536 |
|
| 537 |
@media (max-width: 720px) {
|
| 538 |
+
.topbar {
|
| 539 |
+
flex-direction: column;
|
| 540 |
+
align-items: stretch;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
}
|
| 542 |
|
| 543 |
+
.page-nav {
|
| 544 |
+
width: 100%;
|
| 545 |
+
justify-content: space-between;
|
| 546 |
}
|
| 547 |
|
| 548 |
+
.page-nav-btn {
|
| 549 |
+
flex: 1;
|
| 550 |
}
|
| 551 |
|
| 552 |
+
.page-section {
|
| 553 |
+
padding-right: 10px;
|
|
|
|
|
|
|
| 554 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
+
.hero-stage,
|
| 557 |
+
.panel-shell {
|
| 558 |
+
padding: 20px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
}
|
| 560 |
|
| 561 |
+
.hero-metrics,
|
| 562 |
+
.catalog-summary,
|
| 563 |
+
.health-grid,
|
| 564 |
+
.provider-grid,
|
| 565 |
+
.scoreboard,
|
| 566 |
+
.timeline-strip {
|
| 567 |
+
grid-template-columns: 1fr;
|
| 568 |
}
|
| 569 |
}
|