cacodex commited on
Commit
b0f7d3f
·
verified ·
1 Parent(s): 9c1527e

Upload 10 files

Browse files
Files changed (7) hide show
  1. .env.example +7 -10
  2. README.md +92 -135
  3. app/main.py +507 -933
  4. requirements.txt +2 -5
  5. static/index.html +78 -42
  6. static/public.js +191 -99
  7. static/style.css +345 -551
.env.example CHANGED
@@ -1,11 +1,8 @@
1
- PASSWORD=change-me
2
- SESSION_SECRET=change-me-too
3
- PASS_APIKEY=change-me-api-key
4
- NVIDIA_API_BASE=https://integrate.api.nvidia.com/v1
5
- NVIDIA_NIM_API_KEY=
6
- HEALTHCHECK_INTERVAL_MINUTES=60
7
- HEALTHCHECK_PROMPT=请只回复 OK。
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
- 这是一个基于 FastAPI 的兼容层项目,来把 NVIDIA 官方接口:
11
-
12
- `https://integrate.api.nvidia.com/v1/chat/completions`
13
-
14
- 转换为 OpenAI 风格的 `/v1/responses` 接口,并附带一个公开健康看板和一个中文后台管理系统。
15
-
16
- ## 已支持能力
17
-
18
- - `POST /v1/responses`
19
- - `GET /v1/models`
20
- - `GET /v1/responses/{response_id}`
21
- - tool calling / function calling 转换
22
- - `function_call_output` 回灌转换
23
- - `previous_response_id` 对话续写
24
- - `PASS_APIKEY` 鉴权保护 `/v1/responses`
25
- - 多个 NVIDIA NIM Key 轮询分
26
- - 共享 HTTP 连接池,支持高并发转发
27
- - 模型管理
28
- - NVIDIA NIM Key 管理
29
- - 后台键测试全部模型
30
- - 按小时健康巡检与公开状态页展示
31
- - Docker 方式部署到 Hugging Face Space
32
-
33
- ## 预置模型
34
-
35
- 首次启动会自动写入以下模型:
36
-
37
- - `z-ai/glm5`
38
- - `minimaxai/minimax-m2.5`
39
- - `moonshotai/kimi-k2.5`
40
- - `deepseek-ai/deepseek-v3.2`
41
- - `google/gemma-4-31b-it`
42
- - `qwen/qwen3.5-397b-a17b`
43
-
44
- 你也可以在后台继续添加、删除和测试模型。
45
-
46
- ## 页面与接口
47
-
48
- 公开页面:
49
-
50
- - `GET /` 模型健康度看板
51
- - `GET /api/health/public` 公开健康数据
52
-
53
- 兼容接口
54
-
55
- - `POST /v1/responses`
56
- - `GET /v1/models`
57
- - `GET /v1/responses/{response_id}`
58
-
59
- 后台页面:
60
-
61
- - `GET /admin`
62
- - `POST /admin/api/login`
63
- - `GET /admin/api/overview`
64
- - `GET/POST/DELETE /admin/api/models...`
65
- - `GET/POST/DELETE /admin/api/keys...`
66
- - `GET /admin/api/healthchecks`
67
- - `POST /admin/api/healthchecks/run`
68
- - `GET/PUT /admin/api/settings`
69
-
70
- ## 环境变量
71
-
72
- - `PASSWORD`后台登录密码,必填
73
- - `SESSION_SECRET`:后台会话签名密钥,可选;默认回退到 `PASSWORD`
74
- - `PASS_APIKEY`:外部调用 `/v1/responses` 时使用的鉴权密钥,支持 `Authorization: Bearer ...` `X-API-Key`
75
- - `NVIDIA_API_BASE`:默认 `https://integrate.api.nvidia.com/v1`
76
- - `NVIDIA_NIM_API_KEY`:可选,首次启动时自动导入为默认 Key
77
- - `HEALTHCHECK_INTERVAL_MINUTES`默认 `60`
78
- - `HEALTHCHECK_PROMPT`:默认 `请只回复 OK`
79
- - `PUBLIC_HISTORY_HOURS`:默认 `48`
80
- - `MAX_UPSTREAM_CONNECTIONS`:默认 `256`
81
- - `MAX_KEEPALIVE_CONNECTIONS`:默认 `64`
82
- - `DATABASE_PATH`:默认 `./data.sqlite3`
83
-
84
- 示例配置见 `.env.example`
85
-
86
- ## 本地运行
87
-
88
- 安装运行依赖:
89
-
90
- ```bash
91
- pip install -r requirements.txt
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 apscheduler.schedulers.asyncio import AsyncIOScheduler
16
- from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
17
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
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
- DEFAULT_HEALTH_INTERVAL_MINUTES = int(os.getenv("HEALTHCHECK_INTERVAL_MINUTES", "60"))
36
- DEFAULT_HEALTH_PROMPT = os.getenv("HEALTHCHECK_PROMPT", "请只回复 OK。")
37
- PUBLIC_HISTORY_HOURS = int(os.getenv("PUBLIC_HISTORY_HOURS", "48"))
38
- MAX_UPSTREAM_CONNECTIONS = int(os.getenv("MAX_UPSTREAM_CONNECTIONS", "256"))
39
- MAX_KEEPALIVE_CONNECTIONS = int(os.getenv("MAX_KEEPALIVE_CONNECTIONS", "64"))
40
-
41
- DEFAULT_MODELS = [
42
- ("z-ai/glm5", "GLM-5", "Reasoning and general assistant model from Z.ai", 10, 1),
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
- api_key_selection_lock: asyncio.Lock | None = None
53
- api_key_rr_index = 0
 
 
54
 
55
 
56
  def utcnow() -> datetime:
@@ -61,48 +50,38 @@ def utcnow_iso() -> str:
61
  return utcnow().isoformat()
62
 
63
 
64
- def parse_datetime(value: str | None) -> datetime | None:
65
- if not value:
66
- return None
67
- try:
68
- return datetime.fromisoformat(value)
69
- except ValueError:
70
- return None
71
 
72
 
73
- def bool_value(value: Any) -> bool:
74
- if isinstance(value, bool):
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 json_dumps(value: Any) -> str:
84
- return json.dumps(value, ensure_ascii=False)
 
 
 
 
85
 
86
 
87
- async def get_http_client() -> httpx.AsyncClient:
88
- global http_client
89
- if http_client is None or http_client.is_closed:
90
- limits = httpx.Limits(
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
- async def get_api_key_selection_lock() -> asyncio.Lock:
99
- global api_key_selection_lock
100
- if api_key_selection_lock is None:
101
- api_key_selection_lock = asyncio.Lock()
102
- return api_key_selection_lock
 
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
- id INTEGER PRIMARY KEY AUTOINCREMENT,
162
- response_id TEXT UNIQUE NOT NULL,
163
  parent_response_id TEXT,
164
- model_id INTEGER,
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 TABLE IF NOT EXISTS health_check_records (
175
- id INTEGER PRIMARY KEY AUTOINCREMENT,
176
- model_id INTEGER NOT NULL,
177
- api_key_id INTEGER,
178
- ok INTEGER NOT NULL,
179
- status_code INTEGER,
180
- latency_ms REAL,
181
- error_message TEXT,
182
- response_excerpt TEXT,
183
- checked_at TEXT NOT NULL
 
184
  );
185
 
186
- CREATE TABLE IF NOT EXISTS settings (
187
- key TEXT PRIMARY KEY,
188
- value TEXT NOT NULL
 
 
 
 
 
 
 
 
 
 
 
189
  );
190
  """
191
  )
192
-
193
- now = utcnow_iso()
194
- for model_id, display_name, description, sort_order, featured in DEFAULT_MODELS:
195
- conn.execute(
196
- """
197
- INSERT OR IGNORE INTO proxy_models (
198
- model_id, display_name, provider, description, enabled, featured, sort_order, created_at, updated_at
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 get_setting(conn: sqlite3.Connection, key: str, default: str) -> str:
228
- row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
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
- def verify_admin_token(token: str) -> bool:
263
- serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="nim-admin-auth")
264
  try:
265
- payload = serializer.loads(token, max_age=60 * 60 * 24 * 7)
266
- except (BadSignature, SignatureExpired):
267
- return False
268
- return payload.get("role") == "admin"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
 
271
- def require_admin(request: Request, authorization: str | None = Header(default=None)) -> bool:
272
- token: str | None = None
273
- if authorization and authorization.startswith("Bearer "):
274
- token = authorization.removeprefix("Bearer ").strip()
275
- if not token:
276
- token = request.cookies.get(COOKIE_NAME)
277
- if not token or not verify_admin_token(token):
278
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="需要管理员登录。")
279
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
 
282
- def require_proxy_token_if_configured(authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None)) -> bool:
283
- if not PASS_API_KEY:
284
- return True
 
 
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="缺少 API 鉴权信息。")
292
- if token != PASS_API_KEY:
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
- "type": "function_call",
462
- "call_id": item.get("call_id") or f"call_{uuid.uuid4().hex[:12]}",
463
- "name": item.get("name"),
464
- "arguments": arguments,
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({"id": part.get("id") or part.get("call_id") or f"call_{uuid.uuid4().hex[:12]}", "name": part.get("name"), "arguments": arguments})
 
 
 
 
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({"id": tool_call.get("id") or f"call_{uuid.uuid4().hex[:12]}", "name": function_data.get("name") or tool_call.get("name"), "arguments": arguments})
 
 
 
 
 
 
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({"id": f"msg_{uuid.uuid4().hex[:24]}", "type": "message", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": assistant_text, "annotations": []}]})
 
 
 
 
 
 
703
  for tool_call in tool_calls:
704
- output_items.append({"id": f"fc_{uuid.uuid4().hex[:24]}", "type": "function_call", "status": "completed", "call_id": tool_call["id"], "name": tool_call.get("name"), "arguments": tool_call.get("arguments", "{}")})
 
 
 
 
 
 
 
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": {"input_tokens": usage.get("prompt_tokens"), "output_tokens": usage.get("completion_tokens"), "total_tokens": usage.get("total_tokens")},
 
 
 
 
719
  "choices": build_choice_alias(output_items, finish_reason),
720
- "upstream": {"id": upstream_json.get("id"), "object": upstream_json.get("object", "chat.completion"), "finish_reason": finish_reason or "stop"},
 
 
 
 
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 update_usage_stats(conn: sqlite3.Connection, model_row: sqlite3.Row, api_key_row: sqlite3.Row, *, ok: bool, latency_ms: float | None, is_healthcheck: bool) -> None:
747
- now = utcnow_iso()
748
- if is_healthcheck:
 
 
749
  conn.execute(
750
  """
751
- UPDATE proxy_models
752
- SET healthcheck_count = healthcheck_count + 1,
753
- healthcheck_success_count = healthcheck_success_count + ?,
754
- last_healthcheck_at = ?,
755
- last_health_status = ?,
756
- last_latency_ms = ?,
757
- updated_at = ?
758
- WHERE id = ?
759
  """,
760
- (1 if ok else 0, now, 1 if ok else 0, latency_ms, now, model_row["id"]),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  )
762
  conn.execute(
763
  """
764
- UPDATE api_keys
765
- SET healthcheck_count = healthcheck_count + 1,
766
- healthcheck_success_count = healthcheck_success_count + ?,
767
- last_tested_at = ?,
768
- last_latency_ms = ?,
 
 
 
 
 
 
 
 
 
 
769
  updated_at = ?
770
- WHERE id = ?
771
  """,
772
- (1 if ok else 0, now, latency_ms, now, api_key_row["id"]),
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
- @app.post("/admin/api/models")
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
- INSERT INTO proxy_models (model_id, display_name, provider, description, enabled, featured, sort_order, created_at, updated_at)
1137
- VALUES (?, ?, 'nvidia-nim', ?, ?, ?, ?, ?, ?)
1138
- ON CONFLICT(model_id) DO UPDATE SET
1139
- display_name = excluded.display_name,
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
- (model_id, display_name, body.get("description"), 1 if body.get("enabled", True) else 0, 1 if body.get("featured", False) else 0, int(body.get("sort_order", 0)), now, now),
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 delete_model_internal(model_identifier: str) -> dict[str, Any]:
 
 
1156
  conn = get_db_connection()
1157
  try:
1158
- row = fetch_model_by_identifier(conn, model_identifier)
1159
- if not row:
1160
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
1161
- conn.execute("DELETE FROM proxy_models WHERE id = ?", (row["id"],))
1162
- conn.commit()
1163
- return {"message": "??????"}
 
 
 
 
 
 
 
 
 
 
1164
  finally:
1165
  conn.close()
1166
 
1167
 
1168
- @app.delete("/admin/api/models/{model_identifier}")
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 = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
 
 
 
1186
  if not row:
1187
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
1188
- api_key_row = await select_api_key(conn, payload.get("api_key_id") if payload else None)
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
- @app.post("/admin/api/keys")
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
- now = utcnow_iso()
1238
- conn.execute(
1239
- """
1240
- INSERT INTO api_keys (name, api_key, enabled, created_at, updated_at)
1241
- VALUES (?, ?, ?, ?, ?)
1242
- ON CONFLICT(name) DO UPDATE SET api_key = excluded.api_key, enabled = excluded.enabled, updated_at = excluded.updated_at
1243
- """,
1244
- (name, api_key, 1 if body.get("enabled", True) else 0, now, now),
1245
- )
1246
- conn.commit()
1247
- row = fetch_key_by_identifier(conn, name)
1248
- return {"item": row_to_key_item(row)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  finally:
1250
  conn.close()
1251
 
1252
 
1253
- def delete_key_internal(key_identifier: str) -> dict[str, Any]:
1254
- conn = get_db_connection()
1255
- try:
1256
- row = fetch_key_by_identifier(conn, key_identifier)
1257
- if not row:
1258
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="??? API Key?")
1259
- conn.execute("DELETE FROM api_keys WHERE id = ?", (row["id"],))
1260
- conn.commit()
1261
- return {"message": "API Key ????"}
1262
- finally:
1263
- conn.close()
 
 
 
 
 
 
 
 
1264
 
1265
 
1266
- @app.delete("/admin/api/keys/{key_identifier}")
1267
- async def admin_delete_key(key_identifier: str, _: bool = Depends(require_admin)):
1268
- return delete_key_internal(key_identifier)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1269
 
1270
 
1271
- @app.post("/admin/api/keys/remove")
1272
- async def admin_remove_key_alias(request: Request, _: bool = Depends(require_admin)):
1273
- body = await request.json()
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
- async def test_key_internal(key_identifier: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
1281
- conn = get_db_connection()
 
 
 
 
 
 
 
1282
  try:
1283
- key_row = fetch_key_by_identifier(conn, key_identifier, enabled_only=True)
1284
- if not key_row:
1285
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="??? API Key?")
1286
- model_identifier = (payload or {}).get("model_id") or DEFAULT_MODELS[0][0]
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
- key_rows = conn.execute("SELECT * FROM api_keys WHERE enabled = 1 ORDER BY id ASC").fetchall()
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
- conn.close()
 
 
 
 
 
 
 
 
1312
 
1313
 
1314
- @app.post("/admin/api/keys/test")
1315
- async def admin_test_key_alias(request: Request, _: bool = Depends(require_admin)):
1316
- body = await request.json()
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.post("/admin/api/keys/test-all")
1324
- async def admin_test_all_keys(request: Request, _: bool = Depends(require_admin)):
1325
- body = await request.json() if request.method == "POST" else {}
1326
- results = await test_all_keys_internal(body)
1327
- return {"items": results, "results": results}
1328
 
1329
 
1330
- @app.post("/admin/api/keys/{key_identifier}/test")
1331
- async def admin_test_key(key_identifier: str, request: Request, _: bool = Depends(require_admin)):
1332
- body = await request.json() if request.method == "POST" else {}
1333
- return await test_key_internal(key_identifier, body)
1334
 
1335
 
1336
- @app.get("/admin/api/healthchecks")
1337
- async def admin_healthchecks(hours: int = 48, _: bool = Depends(require_admin)):
1338
- conn = get_db_connection()
1339
- try:
1340
- since = utcnow() - timedelta(hours=hours)
1341
- rows = conn.execute(
1342
- """
1343
- SELECT h.*, m.model_id, m.display_name, k.name AS key_name
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.post("/admin/api/healthchecks/run")
1360
- async def admin_run_healthchecks(request: Request, _: bool = Depends(require_admin)):
1361
- body = await request.json() if request.method == "POST" else {}
1362
- results = await run_healthchecks(model_identifier=body.get("model_id") or body.get("model"), api_key_identifier=body.get("api_key_id") or body.get("key_id"), prompt=body.get("prompt"))
1363
- return {"items": results, "results": results}
1364
 
1365
 
1366
- @app.get("/admin/api/settings")
1367
- async def admin_settings(_: bool = Depends(require_admin)):
1368
- conn = get_db_connection()
1369
- try:
1370
- return get_settings_payload(conn)
1371
- finally:
1372
- conn.close()
1373
 
1374
 
1375
- @app.put("/admin/api/settings")
1376
- async def admin_update_settings(request: Request, _: bool = Depends(require_admin)):
1377
  body = await request.json()
1378
- conn = get_db_connection()
1379
- try:
1380
- set_setting(conn, "healthcheck_enabled", "true" if body.get("healthcheck_enabled", True) else "false")
1381
- set_setting(conn, "healthcheck_interval_minutes", str(max(5, int(body.get("healthcheck_interval_minutes", DEFAULT_HEALTH_INTERVAL_MINUTES)))))
1382
- set_setting(conn, "healthcheck_prompt", body.get("healthcheck_prompt") or DEFAULT_HEALTH_PROMPT)
1383
- if body.get("public_history_hours"):
1384
- set_setting(conn, "public_history_hours", str(max(1, int(body.get("public_history_hours")))))
1385
- conn.commit()
1386
- finally:
1387
- conn.close()
1388
- schedule_healthchecks()
1389
- conn = get_db_connection()
 
1390
  try:
1391
- return get_settings_payload(conn)
1392
- finally:
1393
- conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>NVIDIA NIM 模型健康看板</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="public-body">
17
  <div class="ambient ambient-left"></div>
18
  <div class="ambient ambient-right"></div>
19
- <main class="public-shell">
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
- <section class="summary-panel">
37
- <div class="section-heading">
38
- <div>
39
- <span class="section-tag">公开状态页</span>
40
- <h2>最近 12 次巡检趋势</h2>
41
- </div>
42
- <div class="refresh-meta">
43
- <span>最近刷新</span>
44
- <strong id="last-updated">--</strong>
45
- </div>
46
- </div>
47
- <div class="summary-strip" id="summary-chips"></div>
48
- </section>
49
 
50
- <section class="board-panel">
51
- <div class="board-head">
52
- <div>
53
- <span class="section-tag">模型矩阵</span>
54
- <h2>健康状态总览</h2>
55
- </div>
56
- <p class="board-note">绿色表示正常,橙色表示波动,红色表示异常,灰色表示尚未巡检。</p>
57
- </div>
58
- <div class="model-grid" id="model-grid"></div>
59
- <p class="status-text error-text" id="error-text"></p>
60
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 summaryChips = document.getElementById("summary-chips");
2
- const modelGrid = document.getElementById("model-grid");
3
- const lastUpdated = document.getElementById("last-updated");
4
- const errorText = document.getElementById("error-text");
5
-
6
- const STATUS_LABELS = {
7
- healthy: "正常",
8
- degraded: "波动",
9
- down: "异常",
10
- unknown: "未巡检",
11
- };
12
-
13
- const STATUS_CLASS = {
14
- healthy: "ok",
15
- degraded: "warn",
16
- down: "down",
17
- unknown: "idle",
 
 
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 formatHourSegment(segment) {
35
- const span = document.createElement("span");
36
- const date = new Date(segment.time || segment.hour);
37
- span.textContent = Number.isNaN(date.getTime()) ? "--" : String(date.getHours()).padStart(2, "0");
38
- span.className = `timeline-item ${STATUS_CLASS[segment.status] || "idle"}`;
39
- span.title = `${STATUS_LABELS[segment.status] || "未巡检"} ${formatDateTime(segment.time || segment.hour)}`;
40
- return span;
41
  }
42
 
43
- function createSummaryChip(label, value, tone = "default") {
44
- const chip = document.createElement("div");
45
- chip.className = `summary-chip ${tone}`;
46
- chip.innerHTML = `<span>${label}</span><strong>${value}</strong>`;
47
- return chip;
 
 
48
  }
49
 
50
- function renderSummary(models) {
51
- summaryChips.innerHTML = "";
52
- const total = models.length;
53
- const healthy = models.filter((item) => item.status === "healthy").length;
54
- const issues = models.filter((item) => item.status === "down").length;
55
- const latest = models.reduce((max, item) => {
56
- if (!item.last_healthcheck_at) return max;
57
- return !max || new Date(item.last_healthcheck_at) > new Date(max) ? item.last_healthcheck_at : max;
58
- }, null);
59
-
60
- summaryChips.appendChild(createSummaryChip("监控模型", total));
61
- summaryChips.appendChild(createSummaryChip("健康模型", healthy, "good"));
62
- summaryChips.appendChild(createSummaryChip("异常模型", issues, issues > 0 ? "danger" : "default"));
63
- summaryChips.appendChild(createSummaryChip("最近探测", latest ? formatDateTime(latest) : "暂无数据"));
64
- }
 
 
 
 
 
 
65
 
66
- function renderModel(model) {
67
  const card = document.createElement("article");
68
- card.className = "model-card";
69
- const status = model.status || "unknown";
70
- const points = (model.hourly || []).slice(-12);
71
- const successRate = typeof model.success_rate === "number" ? `${model.success_rate.toFixed(1)}%` : model.beat || "--";
72
-
73
- card.innerHTML = `
74
- <div class="card-top">
75
- <div>
76
- <div class="card-title">${model.display_name || model.name || model.model_id}</div>
77
- <div class="model-subtitle">${model.model_id || "--"}</div>
78
- </div>
79
- <span class="status-chip ${status}">${STATUS_LABELS[status] || "未巡检"}</span>
80
- </div>
81
- <div class="metric-row">
82
- <div class="metric-pill">
83
- <span>成功率</span>
84
- <strong>${successRate}</strong>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  </div>
86
- <div class="metric-pill">
87
- <span>最近探测</span>
88
- <strong>${formatDateTime(model.last_healthcheck_at)}</strong>
 
 
 
 
 
 
 
 
 
 
89
  </div>
90
- </div>
91
- `;
92
-
93
- const timeline = document.createElement("div");
94
- timeline.className = "timeline";
95
-
96
- if (points.length === 0) {
97
- const empty = document.createElement("div");
98
- empty.className = "empty-state";
99
- empty.textContent = "暂无巡检记录";
100
- timeline.appendChild(empty);
101
- } else {
102
- points.forEach((segment) => timeline.appendChild(formatHourSegment(segment)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
- card.appendChild(timeline);
106
- return card;
 
 
 
 
107
  }
108
 
109
- async function loadHealth() {
 
 
 
 
 
 
 
 
 
110
  try {
111
- errorText.textContent = "";
112
- const response = await fetch("/api/health/public", { headers: { Accept: "application/json" } });
113
- if (!response.ok) {
114
- throw new Error("健康接口暂时不可用");
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
- loadHealth();
132
- setInterval(loadHealth, 60 * 1000);
 
 
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(9, 15, 29, 0.82);
5
- --panel-strong: rgba(12, 20, 38, 0.94);
6
- --panel-soft: rgba(255, 255, 255, 0.04);
7
- --text: #f4f7ff;
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
- --green: #35f0a1;
13
- --green-strong: #6bffd0;
14
- --orange: #ffb44f;
15
- --red: #ff6e83;
16
- --shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
17
- --glow: 0 0 0 1px rgba(107, 255, 208, 0.16), 0 22px 54px rgba(30, 255, 179, 0.12);
 
 
 
 
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.03) 1px, transparent 1px),
49
- linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
50
- background-size: 34px 34px;
51
- opacity: 0.12;
52
  pointer-events: none;
53
  }
54
 
55
  .ambient {
56
  position: fixed;
57
- width: 32rem;
58
- height: 32rem;
59
  border-radius: 999px;
60
- filter: blur(90px);
61
- opacity: 0.38;
62
  pointer-events: none;
 
63
  }
64
 
65
  .ambient-left {
66
- top: -10rem;
 
 
67
  left: -8rem;
68
- background: rgba(53, 240, 161, 0.28);
69
  }
70
 
71
  .ambient-right {
 
 
72
  top: 8rem;
73
  right: -10rem;
74
- background: rgba(91, 113, 255, 0.22);
75
  }
76
 
77
- .public-shell,
78
- .admin-shell {
79
- position: relative;
80
- z-index: 1;
 
 
81
  }
82
 
83
- .public-shell {
84
- width: min(1280px, calc(100vw - 32px));
 
 
 
85
  margin: 0 auto;
86
- padding: 32px 0 54px;
87
- }
88
-
89
- .hero-panel,
90
- .summary-panel,
91
- .board-panel,
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
- .hero-panel {
123
- display: grid;
124
- grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
125
- gap: 24px;
126
- padding: 34px;
127
  }
128
 
 
129
  .hero-copy h1,
130
- .hero-side .hero-value,
131
- .section-heading h2,
132
- .board-head h2,
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
- gap: 8px;
144
  padding: 8px 14px;
145
  border-radius: 999px;
146
- border: 1px solid rgba(107, 255, 208, 0.28);
147
- background: rgba(53, 240, 161, 0.1);
148
- color: var(--green-strong);
149
  font-size: 13px;
150
  font-weight: 700;
151
  letter-spacing: 0.08em;
152
  }
153
 
154
- .hero-copy h1,
155
- .board-head h2,
156
- .panel-headline h2,
157
- .section-heading h2,
158
- .login-card h2,
159
- .brand-block h1 {
160
- margin: 16px 0 12px;
161
- font-size: clamp(34px, 3vw, 52px);
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
- .hero-side {
176
- padding: 24px;
177
- border-radius: 24px;
178
- background: rgba(255, 255, 255, 0.04);
179
- border: 1px solid var(--line-strong);
180
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
 
181
  }
182
 
183
- .hero-kicker {
 
 
 
184
  color: var(--muted-strong);
185
- letter-spacing: 0.16em;
186
- text-transform: uppercase;
187
- font-size: 12px;
188
  }
189
 
190
- .hero-value {
191
- margin-top: 14px;
192
- font-size: 32px;
193
  font-weight: 700;
194
  }
195
 
196
- .summary-panel,
197
- .board-panel {
198
- padding: 28px 30px;
199
- margin-top: 22px;
 
200
  }
201
 
202
- .section-heading,
203
- .board-head,
204
- .panel-headline,
205
- .toolbar-row {
206
  display: flex;
207
- justify-content: space-between;
208
- align-items: flex-start;
209
- gap: 18px;
210
- flex-wrap: wrap;
211
  }
212
 
213
- .section-heading h2,
214
- .board-head h2,
215
- .panel-headline h2,
216
- .panel-headline h3 {
217
- margin-bottom: 0;
218
- font-size: clamp(24px, 2vw, 34px);
219
  }
220
 
221
- .refresh-meta {
222
- min-width: 220px;
223
- padding: 16px 18px;
224
- border-radius: 20px;
225
- background: rgba(255, 255, 255, 0.04);
 
 
 
 
 
 
 
226
  border: 1px solid var(--line);
 
 
 
 
227
  }
228
 
229
- .refresh-meta span {
230
- display: block;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  color: var(--muted);
232
- font-size: 13px;
233
- margin-bottom: 8px;
234
  }
235
 
236
- .refresh-meta strong {
237
- font-family: var(--font-display);
238
- font-size: 20px;
 
 
239
  }
240
 
241
- .summary-strip {
242
- margin-top: 22px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  display: grid;
244
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
245
  gap: 14px;
 
246
  }
247
 
248
- .summary-chip,
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
- .summary-chip span,
257
- .metric-card h3 {
258
- display: block;
259
  color: var(--muted);
260
  font-size: 13px;
261
- margin-bottom: 10px;
262
  }
263
 
264
- .summary-chip strong,
265
- .metric-card strong {
 
266
  font-family: var(--font-display);
267
- font-size: 28px;
268
- line-height: 1.2;
269
  }
270
 
271
- .summary-chip.good {
272
- box-shadow: var(--glow);
 
273
  }
274
 
275
- .summary-chip.danger {
276
- border-color: rgba(255, 110, 131, 0.32);
277
- background: linear-gradient(180deg, rgba(255, 110, 131, 0.16), rgba(255, 255, 255, 0.03));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  }
279
 
280
- .board-head {
281
- margin-bottom: 18px;
 
 
 
 
 
 
 
 
282
  }
283
 
284
- .model-grid,
285
- .section-grid {
286
  display: grid;
287
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
288
  gap: 18px;
289
  }
290
 
291
- .model-card {
 
292
  padding: 22px;
293
- border-radius: 24px;
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
- .model-card:hover,
300
- .health-record:hover {
301
- transform: translateY(-3px);
302
- border-color: rgba(107, 255, 208, 0.28);
 
303
  box-shadow: var(--glow);
304
  }
305
 
306
- .card-top,
307
- .metric-row,
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
- .card-title {
 
 
322
  font-size: 22px;
323
- font-weight: 700;
324
- line-height: 1.3;
325
  }
326
 
327
- .model-subtitle,
328
- .mono {
329
- font-family: var(--font-display);
330
- color: var(--muted);
331
- font-size: 13px;
332
  }
333
 
334
- .status-chip,
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
- .status-chip.healthy,
349
- .pill.healthy,
350
- .pill.good {
351
- color: var(--green-strong);
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
- .metric-pill {
386
- flex: 1 1 140px;
387
  padding: 14px 16px;
388
  border-radius: 18px;
389
- background: rgba(255, 255, 255, 0.04);
390
- border: 1px solid rgba(255, 255, 255, 0.06);
391
  }
392
 
393
- .metric-pill span {
394
  display: block;
395
  color: var(--muted);
396
  font-size: 12px;
397
- margin-bottom: 8px;
398
  }
399
 
400
- .metric-pill strong {
 
 
401
  font-family: var(--font-display);
402
- font-size: 17px;
403
  }
404
 
405
- .timeline {
406
- display: flex;
 
407
  gap: 10px;
408
- flex-wrap: wrap;
409
  margin-top: 18px;
410
  }
411
 
412
- .timeline-item {
413
- width: 40px;
414
- height: 40px;
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-item.ok {
427
- background: linear-gradient(135deg, rgba(53, 240, 161, 0.92), rgba(107, 255, 208, 0.92));
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
- .empty-state,
449
- .empty-card {
450
- width: 100%;
451
- padding: 18px;
452
- border-radius: 18px;
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
- .error-text {
 
 
 
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
- button:hover,
472
- .secondary-btn:hover {
473
- transform: translateY(-1px);
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: 14px;
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
- box-shadow: none;
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
- .form-grid {
580
- display: grid;
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
- .form-grid textarea {
610
- min-height: 120px;
611
- resize: vertical;
612
- grid-column: 1 / -1;
613
  }
614
 
615
- .checkbox-row {
616
- display: flex;
617
- align-items: center;
618
- gap: 12px;
619
- color: var(--text);
 
620
  }
621
 
622
- .checkbox-row input {
623
- width: 18px;
624
- height: 18px;
625
  }
626
 
627
- .spaced-top {
628
- margin-top: 18px;
 
 
 
 
629
  }
630
 
631
- .health-record,
632
- .empty-card {
633
- padding: 20px;
634
- border-radius: 22px;
635
  }
636
 
637
- .health-record h4,
638
- .login-card h2 {
639
- margin: 0;
 
 
 
640
  }
641
 
642
- .record-meta {
643
- margin-top: 16px;
644
- color: var(--muted);
645
- font-size: 13px;
646
  }
647
 
648
- .login-overlay {
649
- position: fixed;
650
- inset: 0;
651
- display: flex;
652
- align-items: center;
653
- justify-content: center;
654
- background: rgba(3, 6, 14, 0.76);
655
- backdrop-filter: blur(12px);
656
- z-index: 20;
657
  }
658
 
659
- .login-card {
660
- width: min(460px, calc(100vw - 32px));
661
- padding: 30px;
662
- border-radius: 28px;
663
- background: linear-gradient(180deg, rgba(13, 22, 41, 0.96), rgba(8, 12, 24, 0.96));
664
- border: 1px solid rgba(255, 255, 255, 0.12);
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
- .hero-panel {
681
- grid-template-columns: 1fr;
 
682
  }
683
 
684
- .admin-shell {
 
685
  grid-template-columns: 1fr;
686
  }
687
 
688
- .admin-sidebar {
689
- position: sticky;
690
- top: 0;
691
- z-index: 5;
692
  }
693
  }
694
 
695
  @media (max-width: 720px) {
696
- .public-shell {
697
- width: min(100vw - 20px, 1280px);
698
- padding-top: 20px;
699
- }
700
-
701
- .hero-panel,
702
- .summary-panel,
703
- .board-panel,
704
- .glass-panel,
705
- .admin-content {
706
- padding: 20px;
707
  }
708
 
709
- .form-grid {
710
- grid-template-columns: 1fr;
 
711
  }
712
 
713
- .admin-sidebar {
714
- padding: 18px;
715
  }
716
 
717
- .summary-strip,
718
- .model-grid,
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
- .form-grid > button {
760
- min-height: 54px;
761
- justify-self: start;
762
- }
763
-
764
- @media (max-width: 720px) {
765
- .settings-actions {
766
- flex-direction: column;
767
- align-items: stretch;
768
  }
769
 
770
- .settings-actions button,
771
- .panel-actions button,
772
- .form-grid > button {
773
- width: 100%;
 
 
 
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
  }