Files
KiloStar/kilostar/api/__init__.py
T
zhaoxi 457d12834f feat(standalone): 新增单机模式,KILOSTAR_MODE=standalone 时去掉 Ray 依赖
通过 StandaloneProxy 适配层让 .remote() 调用在单机模式下透明降级为
asyncio 协程调用,7 个 Actor 和 workflow task 均可在纯 asyncio 环境运行,
启动快、资源占用低。分布式模式行为完全不变。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 15:52:41 +00:00

190 lines
6.4 KiB
Python

# Copyright 2026 zhaoxi826
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from typing import Dict
from fastapi import FastAPI, WebSocket, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
_STANDALONE = os.environ.get("KILOSTAR_MODE", "distributed") == "standalone"
if not _STANDALONE:
from ray import serve
from .agent import agent_router
from .auth import auth_router
from .system import system_router
from .platform.frontend import client_router
from .platform.onebot import onebot_router
from .provider import provider_router
from .resource import resource_router
from .workflow import workflow_router
from .chat import chat_router
from kilostar.utils.error import (
KiloStarError,
BusinessError,
InfraError,
)
from kilostar.utils.logger import get_logger
from kilostar.utils.request_context import (
bind_request_id,
new_request_id,
reset_request_id,
)
from kilostar.utils.i18n import t
_api_logger = get_logger("api")
def _get_locale(request: Request) -> str | None:
"""从请求头解析首选语言,供异常 handler 使用。"""
return request.headers.get("accept-language") or None
app = FastAPI()
_cors_origins_env = os.environ.get("KILOSTAR_CORS_ORIGINS", "")
_is_dev = os.environ.get("KILOSTAR_ENV", "production").lower() in ("dev", "development")
if not _cors_origins_env and _is_dev:
_cors_origins_env = "*"
elif not _cors_origins_env:
_cors_origins_env = "http://localhost:8000"
_cors_origins = [o.strip() for o in _cors_origins_env.split(",") if o.strip()]
_allow_credentials = "*" not in _cors_origins
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
"""请求级 ``request_id`` 注入。
入口策略:``X-Request-Id`` 头存在则继承(便于网关/前端串联调用链),
否则生成新的 UUID。退出时把它写到响应头,方便客户端日志对账。
contextvars 让同一请求生命周期内所有协程的日志都自动带上这个 ID。
"""
incoming = request.headers.get("X-Request-Id", "").strip()
request_id = incoming or new_request_id()
token = bind_request_id(request_id)
try:
response = await call_next(request)
finally:
reset_request_id(token)
response.headers["X-Request-Id"] = request_id
return response
app.include_router(system_router) # 健康探针 + 系统信息
app.include_router(client_router) # 客户端路径
app.include_router(onebot_router) # OneBot v11 路径
app.include_router(auth_router) # 用户路径
app.include_router(provider_router) # 供应商路径
app.include_router(resource_router) # 资源路径
app.include_router(agent_router) # agent路径
app.include_router(workflow_router) # workflow路径
app.include_router(chat_router) # chat路径
@app.exception_handler(BusinessError)
async def business_error_handler(request: Request, exc: BusinessError):
"""业务可预期错误:按 ``http_status`` 返回 4xx,附 ``code`` + 异常消息。"""
return JSONResponse(
status_code=exc.http_status,
content={"code": exc.code, "message": str(exc) or exc.code},
)
@app.exception_handler(InfraError)
async def infra_error_handler(request: Request, exc: InfraError):
"""系统失败错误:落日志后返回脱敏的 5xx。"""
_api_logger.exception(
f"InfraError on {request.method} {request.url.path}: {exc}"
)
loc = _get_locale(request)
return JSONResponse(
status_code=exc.http_status,
content={"code": exc.code, "message": t("internal_error", accept_language=loc)},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
"""全局兜底:未预期的异常落日志后返回脱敏的 500,避免泄露 traceback。"""
_api_logger.exception(
f"Unhandled exception on {request.method} {request.url.path}: {exc}"
)
loc = _get_locale(request)
return JSONResponse(
status_code=500,
content={"code": "internal_error", "message": t("internal_error", accept_language=loc)},
)
base_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
frontend_dir = os.path.join(base_dir, "frontend", "dist")
if os.path.exists(frontend_dir):
app.mount(
"/assets",
StaticFiles(directory=os.path.join(frontend_dir, "assets")),
name="assets",
)
@app.get("/favicon.svg", include_in_schema=False)
async def serve_favicon():
return FileResponse(os.path.join(frontend_dir, "favicon.svg"))
@app.get("/icons.svg", include_in_schema=False)
async def serve_icons():
return FileResponse(os.path.join(frontend_dir, "icons.svg"))
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str):
# 【重要安全修复】避免拦截不存在的 API 路由。如果是调用了不存在的 /api/ 接口,直接返回 404,不返回前端页面
if full_path.startswith("api/"):
return JSONResponse(
status_code=404, content={"detail": "API endpoint not found"}
)
index_path = os.path.join(frontend_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return JSONResponse(
status_code=404, content={"detail": t("frontend_not_found")}
)
else:
import logging
logging.getLogger("kilostar").warning(
f"Frontend dist folder not found at {frontend_dir}. Skipping frontend mount."
)
if not _STANDALONE:
@serve.deployment
@serve.ingress(app)
class KiloStarGateway:
gateway: Dict[str, WebSocket]
def __init__(self):
self.gateway = {}