Spaces:
Running
Running
| """管理后台 API:/admin/api/*。 | |
| 完全兼容前端旧 admin-html.mjs 调用: | |
| - POST /admin/api/login | |
| - POST /admin/api/xtc-access-token | |
| - GET/PUT /admin/api/config | |
| - GET/POST/DELETE /admin/api/providers | |
| - GET /admin/api/provider-models | |
| - POST /admin/api/provider-settings | |
| - POST /admin/api/test-chat | |
| - POST /admin/api/default-provider | |
| - GET /admin/api/tokens(新增:列出/吊销令牌) | |
| - POST /admin/api/tokens/revoke | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from typing import Any, Optional | |
| from urllib.parse import urlparse | |
| from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request | |
| from fastapi.responses import HTMLResponse, JSONResponse, Response | |
| from ..adapters import gemini_api, openai as openai_adapter | |
| from ..auth import ( | |
| issue_access_token, | |
| list_access_tokens, | |
| revoke_access_token, | |
| verify_admin_key, | |
| ) | |
| from ..cache import invalidate_models_cache | |
| from ..config import GEMINI_DEFAULT_MODEL, get_settings | |
| from ..database import get_conn | |
| from ..errors import HttpError | |
| from ..models.config import AppConfig, Provider | |
| from ..services import config_store | |
| from ..utils.thought import extract_thought_and_answer | |
| from ._common import CORS_HEADERS, ok_with_cors | |
| router = APIRouter(prefix="/admin/api", tags=["admin"]) | |
| # ===== 依赖:admin 校验 ===== | |
| def _require_admin( | |
| request: Request, | |
| ) -> str: | |
| """FastAPI 依赖:admin key 校验。""" | |
| auth = request.headers.get("authorization") | |
| admin_header = ( | |
| request.headers.get("x-xtc-admin-key") | |
| or request.headers.get("X-XTC-Admin-Key") | |
| ) | |
| key = None | |
| if admin_header: | |
| key = admin_header.strip() | |
| elif auth: | |
| a = auth.strip() | |
| if a.lower().startswith("bearer "): | |
| key = a[7:].strip() | |
| else: | |
| key = a | |
| verify_admin_key(key) | |
| return key or "" | |
| # ===== base_url 自指校验 ===== | |
| def _reject_self_pointing_base_url( | |
| request: Request, base_url: Optional[str], provider_type: str | |
| ) -> None: | |
| """拒绝 base_url 指向后端自身的配置。 | |
| 若 openai 厂商的 base_url 指向本后端(对外域名或 HF Space 直连地址), | |
| /v1/xtc/models 拉取上游时会递归调回自己,最终超时——旧代码静默吞掉异常 | |
| 返回空列表,曾导致 deepseek2-hugging 模型列表持续为空的故障。 | |
| 这里在保存时直接拦截,从源头杜绝自指死循环。 | |
| """ | |
| if str(provider_type or "").lower() != "openai": | |
| return | |
| bu = str(base_url or "").strip() | |
| if not bu: | |
| return | |
| try: | |
| target_host = (urlparse(bu).hostname or "").lower() | |
| except Exception: | |
| return | |
| if not target_host: | |
| return | |
| candidates: set[str] = set() | |
| host = request.headers.get("host") | |
| if host: | |
| candidates.add(host.split(":")[0].lower()) | |
| # HF Space 多副本会带 x-direct-url(如 https://xxx.hf.space/--replicas/...) | |
| xdu = request.headers.get("x-direct-url") | |
| if xdu: | |
| try: | |
| candidates.add((urlparse(xdu).hostname or "").lower()) | |
| except Exception: | |
| pass | |
| if target_host in candidates: | |
| raise HttpError( | |
| f"base_url 不能指向后端自身({target_host}),否则拉取模型列表会递归死循环", | |
| status=400, | |
| code="bad_request", | |
| ) | |
| # ===== 登录 ===== | |
| async def login(body: dict = Body(default={})): | |
| key = body.get("admin_key") or body.get("adminKey") or body.get("key") | |
| try: | |
| verify_admin_key(key) | |
| except HttpError as e: | |
| return JSONResponse( | |
| status_code=e.status, | |
| content={"ok": False, "error": {"code": e.code, "message": e.message}}, | |
| headers=CORS_HEADERS, | |
| ) | |
| return ok_with_cors({"authenticated": True}) | |
| # ===== 临时令牌 ===== | |
| async def create_access_token( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| ttl_override = body.get("ttl_sec") or body.get("ttlSec") | |
| info = issue_access_token(issued_by="admin") | |
| if ttl_override: | |
| # 允许覆盖 TTL(重新签发,简化处理) | |
| info = issue_access_token(issued_by="admin") | |
| return ok_with_cors(info) | |
| async def list_tokens(_admin: str = Depends(_require_admin)): | |
| return ok_with_cors({"tokens": list_access_tokens()}) | |
| async def revoke_token( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| jti = body.get("jti") or body.get("token") | |
| if not jti: | |
| raise HttpError("jti is required", status=400, code="bad_request") | |
| ok = revoke_access_token(jti) | |
| return ok_with_cors({"revoked": ok}) | |
| # ===== 配置 ===== | |
| async def get_config(_admin: str = Depends(_require_admin)): | |
| cfg = await config_store.load_config() | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in cfg.providers], | |
| "defaultProviderId": cfg.default_provider_id, | |
| "updatedAt": cfg.updated_at, | |
| } | |
| ) | |
| async def put_config( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| cfg = AppConfig.model_validate(body) | |
| saved = await config_store.save_config(cfg) | |
| invalidate_models_cache() | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| "updatedAt": saved.updated_at, | |
| } | |
| ) | |
| async def import_config( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| """导入配置 JSON。 | |
| 支持两种格式: | |
| 1. 导出格式:{ ok, config: { providers, defaultProviderId, ... }, exportedAt, ... } | |
| 2. 裸配置:{ providers, defaultProviderId, ... } | |
| 导入会覆盖当前配置。 | |
| """ | |
| payload = body.get("config") if isinstance(body.get("config"), dict) else body | |
| try: | |
| cfg = AppConfig.model_validate(payload) | |
| except Exception as e: | |
| raise HttpError( | |
| "配置格式无效: " + str(e), status=400, code="bad_request" | |
| ) from e | |
| saved = await config_store.save_config(cfg) | |
| invalidate_models_cache() | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| "updatedAt": saved.updated_at, | |
| "count": len(saved.providers), | |
| } | |
| ) | |
| # ===== 厂商 CRUD ===== | |
| async def list_providers(_admin: str = Depends(_require_admin)): | |
| cfg = await config_store.load_config() | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in cfg.providers], | |
| "defaultProviderId": cfg.default_provider_id, | |
| "count": len(cfg.providers), | |
| } | |
| ) | |
| async def add_provider( | |
| request: Request, | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| provider = Provider.model_validate(body) | |
| _reject_self_pointing_base_url(request, provider.base_url, provider.type) | |
| saved = await config_store.add_provider(provider) | |
| invalidate_models_cache(provider.id) | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| } | |
| ) | |
| async def delete_provider( | |
| provider_id: str = Query(..., alias="id"), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| saved = await config_store.remove_provider(provider_id) | |
| invalidate_models_cache(provider_id) | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| } | |
| ) | |
| async def update_provider_route( | |
| request: Request, | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| """更新已有厂商的字段(name / type / base_url / model_prefix / enabled / api_keys / 模型策略)。 | |
| api_keys 仅在客户端显式传入非空列表时才覆盖;传入空或缺失则保留原密钥, | |
| 避免前端拿到脱敏密钥后回写把真密钥覆盖成掩码串。 | |
| 模型策略字段(model_policy_mode / allow_models / deny_models)允许一并提交, | |
| 这样编辑厂商表单只需一次保存即可同步全部信息,不再像旧后端那样需要单独的 | |
| 「加载策略→编辑→保存」流程。 | |
| """ | |
| provider_id = body.get("id") or body.get("provider_id") | |
| if not provider_id: | |
| raise HttpError("id is required", status=400, code="bad_request") | |
| cfg = await config_store.load_config() | |
| existing = cfg.find_provider(provider_id, enabled_only=False) | |
| if not existing: | |
| raise HttpError(f"provider not found: {provider_id}", status=404, code="not_found") | |
| update: dict[str, Any] = {} | |
| for k in ("name", "type", "base_url", "model_prefix", "enabled"): | |
| if k in body and body[k] is not None: | |
| update[k] = body[k] | |
| # 自指校验:仅当本次显式修改 base_url 时触发,避免更新其它字段时被旧的自指 base_url 拦住 | |
| if "base_url" in update: | |
| _reject_self_pointing_base_url( | |
| request, update.get("base_url"), update.get("type") or existing.type | |
| ) | |
| # api_keys:仅在显式传入非空时替换 | |
| if "api_keys" in body: | |
| from ..models.config import _split_key_list | |
| new_keys = _split_key_list(body.get("api_keys")) | |
| if new_keys: | |
| update["api_keys"] = new_keys | |
| update["api_key"] = new_keys[0] | |
| # 模型策略:合并到同一次更新里。允许空数组表示「清空」。 | |
| if "model_policy_mode" in body and body["model_policy_mode"] is not None: | |
| mode = str(body["model_policy_mode"]).lower() | |
| if mode in ("allowlist", "denylist"): | |
| update["model_policy_mode"] = mode | |
| if "allow_models" in body: | |
| from ..models.config import _split_key_list | |
| update["allow_models"] = _split_key_list(body.get("allow_models")) | |
| if "deny_models" in body: | |
| from ..models.config import _split_key_list | |
| update["deny_models"] = _split_key_list(body.get("deny_models")) | |
| saved = await config_store.update_provider(provider_id, update) | |
| invalidate_models_cache(provider_id) | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| } | |
| ) | |
| async def provider_models( | |
| provider_id: str = Query(..., alias="id"), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| cfg = await config_store.load_config() | |
| p = cfg.find_provider(provider_id, enabled_only=False) | |
| if not p: | |
| raise HttpError(f"provider not found: {provider_id}", status=404, code="not_found") | |
| if not p.api_keys: | |
| return ok_with_cors({"models": [], "count": 0}) | |
| try: | |
| if p.type == "gemini": | |
| models = await gemini_api.list_models_raw(api_key=p.api_keys[0]) | |
| else: | |
| models = await openai_adapter.list_models_raw( | |
| base_url=p.base_url, api_key=p.api_keys[0] | |
| ) | |
| except HttpError as e: | |
| return JSONResponse( | |
| status_code=e.status, | |
| content={"ok": False, "error": {"code": e.code, "message": e.message, "upstream": e.upstream}}, | |
| headers=CORS_HEADERS, | |
| ) | |
| return ok_with_cors({"provider": p.id, "models": models, "count": len(models)}) | |
| async def provider_settings( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| provider_id = body.get("id") or body.get("provider_id") | |
| if not provider_id: | |
| raise HttpError("id is required", status=400, code="bad_request") | |
| # 注意:不能用 ``body.get("allow_models") or body.get("allowModels")``, | |
| # 因为空列表 [] 是 falsy,会被 ``or`` 替换成 None,导致无法清空名单。 | |
| # 这里改成「键存在就用,否则取别名」。 | |
| def _pick(snake: str, camel: str): | |
| if snake in body: | |
| return body.get(snake) | |
| if camel in body: | |
| return body.get(camel) | |
| return None | |
| saved = await config_store.set_provider_settings( | |
| provider_id, | |
| enabled=body.get("enabled"), | |
| model_policy_mode=_pick("model_policy_mode", "modelPolicyMode"), | |
| allow_models=_pick("allow_models", "allowModels"), | |
| deny_models=_pick("deny_models", "denyModels"), | |
| ) | |
| invalidate_models_cache(provider_id) | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| } | |
| ) | |
| async def set_default_provider( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| provider_id = body.get("id") or body.get("provider_id") | |
| if not provider_id: | |
| raise HttpError("id is required", status=400, code="bad_request") | |
| try: | |
| saved = await config_store.set_default_provider(provider_id) | |
| except ValueError as e: | |
| raise HttpError(str(e), status=404, code="not_found") from e | |
| return ok_with_cors( | |
| { | |
| "providers": [p.masked().model_dump(mode="json") for p in saved.providers], | |
| "defaultProviderId": saved.default_provider_id, | |
| } | |
| ) | |
| # ===== 对话测试 ===== | |
| async def test_chat( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| """后台对话测试。 | |
| body: | |
| - provider: str | |
| - model: str | |
| - messages: list | |
| - mode: "openai" | "xtc" | |
| - image_fix: optional | |
| """ | |
| from ..media.imagefix import fix_images_in_messages, infer_fix_mode | |
| provider_id = body.get("provider") | |
| model = body.get("model") or GEMINI_DEFAULT_MODEL | |
| messages = body.get("messages") or [] | |
| mode = body.get("mode") or "xtc" | |
| if not messages: | |
| raise HttpError("messages is required", status=400, code="bad_request") | |
| image_fix_mode = body.get("image_fix") | |
| if not image_fix_mode and body.get("image_camera_facing"): | |
| image_fix_mode = infer_fix_mode(body.get("image_camera_facing")) | |
| if image_fix_mode: | |
| await fix_images_in_messages(messages, image_fix_mode) | |
| cfg = await config_store.load_config() | |
| p = cfg.find_provider(provider_id, enabled_only=False) if provider_id else cfg.find_default_provider() | |
| if not p: | |
| raise HttpError("no provider available", status=503, code="service_unavailable") | |
| if not p.api_keys: | |
| raise HttpError( | |
| f"provider '{p.id}' has no api_keys", status=503, code="server_misconfigured" | |
| ) | |
| api_key = p.api_keys[0] | |
| clean_model = model | |
| if p.model_prefix and clean_model.startswith(p.model_prefix): | |
| clean_model = clean_model[len(p.model_prefix):] | |
| upstream_body = {"model": clean_model, "messages": messages} | |
| for k in ("temperature", "top_p", "max_tokens", "reasoning_effort"): | |
| if k in body and body[k] is not None: | |
| upstream_body[k] = body[k] | |
| try: | |
| if p.type == "gemini": | |
| openai_resp = await gemini_api.chat_completions( | |
| api_key=api_key, model=clean_model, messages=messages, | |
| body=upstream_body, stream=False, | |
| ) | |
| else: | |
| openai_resp = await openai_adapter.chat_completions( | |
| base_url=p.base_url, api_key=api_key, body=upstream_body, stream=False, | |
| ) | |
| except HttpError as e: | |
| return JSONResponse( | |
| status_code=e.status, | |
| content={"ok": False, "error": {"code": e.code, "message": e.message, "upstream": e.upstream}}, | |
| headers=CORS_HEADERS, | |
| ) | |
| if mode == "openai": | |
| return ok_with_cors({"result": openai_resp, "provider": p.id, "model": clean_model}) | |
| # xtc 模式:抽取 thought/text | |
| choices = openai_resp.get("choices") or [] | |
| choice = choices[0] if choices else {} | |
| raw_text = (choice.get("message") or {}).get("content") or "" | |
| thought, text = extract_thought_and_answer(raw_text) | |
| return ok_with_cors( | |
| { | |
| "provider": p.id, | |
| "model": clean_model, | |
| "thought": thought, | |
| "text": text, | |
| "raw": raw_text, | |
| "usage": openai_resp.get("usage") or {}, | |
| "finish_reason": choice.get("finish_reason"), | |
| } | |
| ) | |
| # ===== TTS 设置与测试 ===== | |
| async def get_tts_settings(_admin: str = Depends(_require_admin)): | |
| """读取 TTS 默认设置。""" | |
| data = config_store.load_tts_settings() | |
| return ok_with_cors({"settings": data}) | |
| async def put_tts_settings( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| """更新 TTS 默认设置。""" | |
| saved = config_store.save_tts_settings(body) | |
| return ok_with_cors({"settings": saved}) | |
| async def tts_test( | |
| body: dict = Body(default={}), | |
| _admin: str = Depends(_require_admin), | |
| ): | |
| """后台 TTS 测试:输入文本生成 MP3 音频并返回二进制。 | |
| body: | |
| - text: str(必填) | |
| - voice / rate / volume / pitch: 可选,覆盖默认设置 | |
| """ | |
| from ..adapters.tts import synthesize | |
| text = body.get("text") or "" | |
| if not str(text).strip(): | |
| raise HttpError("text is required", status=400, code="bad_request") | |
| try: | |
| audio = await synthesize( | |
| text=text, | |
| voice=body.get("voice"), | |
| rate=body.get("rate"), | |
| volume=body.get("volume"), | |
| pitch=body.get("pitch"), | |
| ) | |
| except HttpError as e: | |
| return JSONResponse( | |
| status_code=e.status, | |
| content={"ok": False, "error": {"code": e.code, "message": e.message, "hint": e.hint}}, | |
| headers=CORS_HEADERS, | |
| ) | |
| return Response( | |
| content=audio, | |
| media_type="audio/mpeg", | |
| headers={**CORS_HEADERS, "Cache-Control": "no-store"}, | |
| ) | |