2026-06-06 17:18:15 +08:00
|
|
|
import asyncio
|
2026-02-14 18:28:37 +08:00
|
|
|
import base64
|
2026-05-24 23:30:32 +08:00
|
|
|
import json
|
2026-02-14 18:28:37 +08:00
|
|
|
import logging
|
2026-03-10 23:10:11 +08:00
|
|
|
import os
|
2026-02-25 19:00:17 +08:00
|
|
|
import uuid
|
2026-06-08 11:51:39 +08:00
|
|
|
from contextlib import suppress
|
2026-06-09 19:18:14 +08:00
|
|
|
from datetime import datetime
|
2026-02-25 19:00:17 +08:00
|
|
|
from typing import Optional
|
|
|
|
|
|
2026-06-06 17:18:15 +08:00
|
|
|
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, Security, UploadFile
|
2026-02-25 19:00:17 +08:00
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2026-05-24 23:30:32 +08:00
|
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
2026-02-25 19:00:17 +08:00
|
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
|
from pydantic import BaseModel
|
2026-02-13 22:00:26 +08:00
|
|
|
|
2026-06-08 11:51:39 +08:00
|
|
|
from audit_store import get_audit_store
|
2026-06-06 17:18:15 +08:00
|
|
|
from docs_store import get_document_store
|
2026-02-18 08:59:28 +08:00
|
|
|
from geoip import get_ip_location_text
|
2026-06-06 15:44:00 +08:00
|
|
|
from job_handlers import (
|
|
|
|
|
_sanitize_converted_markdown,
|
|
|
|
|
sanitize_inline_completion_content,
|
|
|
|
|
ALLOWED_CONVERT_EXTENSIONS,
|
|
|
|
|
asr_handler,
|
|
|
|
|
completion_handler,
|
|
|
|
|
compress_handler,
|
|
|
|
|
convert_handler,
|
|
|
|
|
ocr_handler,
|
|
|
|
|
pro_completion_handler,
|
|
|
|
|
tts_handler,
|
2026-06-09 19:18:14 +08:00
|
|
|
web_search_handler,
|
2026-06-06 15:44:00 +08:00
|
|
|
)
|
|
|
|
|
from job_system import (
|
|
|
|
|
InMemoryJobManager,
|
|
|
|
|
JobSystemError,
|
|
|
|
|
JOB_TYPES,
|
|
|
|
|
QueueFullError,
|
|
|
|
|
RedisJobManager,
|
|
|
|
|
get_job_manager,
|
|
|
|
|
persist_temp_input,
|
|
|
|
|
)
|
2026-06-08 11:51:39 +08:00
|
|
|
from llm_policy import resolve_llm_policy
|
2026-04-11 09:24:14 +08:00
|
|
|
from models import UserPreferences
|
2026-06-08 11:51:39 +08:00
|
|
|
from risk_config import load_risk_config
|
|
|
|
|
from risk_control import RiskDecision, RiskIdentity, RiskRejected, estimate_tokens, get_risk_controller, stable_hash
|
|
|
|
|
from session_store import get_session_store
|
2026-02-14 18:28:37 +08:00
|
|
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
|
|
|
|
|
)
|
|
|
|
|
logger = logging.getLogger("api")
|
2026-06-08 11:51:39 +08:00
|
|
|
config = load_risk_config()
|
2026-01-18 19:42:58 +08:00
|
|
|
|
|
|
|
|
app = FastAPI()
|
2026-02-07 08:53:37 +08:00
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
2026-06-08 11:51:39 +08:00
|
|
|
allow_origins=list(config.cors_allow_origins),
|
2026-02-07 08:53:37 +08:00
|
|
|
allow_credentials=True,
|
|
|
|
|
allow_methods=["*"],
|
2026-02-25 19:00:17 +08:00
|
|
|
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
|
2026-02-07 08:53:37 +08:00
|
|
|
)
|
2026-01-25 13:29:11 +08:00
|
|
|
|
2026-04-11 09:24:14 +08:00
|
|
|
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
|
2026-06-06 15:44:00 +08:00
|
|
|
DOC_COMPRESS_CONTEXT_LIMIT = int(os.getenv("DOC_COMPRESS_CONTEXT_LIMIT", "128000"))
|
2026-06-08 11:51:39 +08:00
|
|
|
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
|
|
|
|
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
2026-06-06 15:44:00 +08:00
|
|
|
_handlers_registered = False
|
2026-02-19 18:18:47 +08:00
|
|
|
|
2026-02-19 10:22:27 +08:00
|
|
|
|
2026-01-18 19:42:58 +08:00
|
|
|
class CompletionRequest(BaseModel):
|
|
|
|
|
prefix: str
|
|
|
|
|
suffix: str
|
2026-02-25 19:00:17 +08:00
|
|
|
languageId: str = "markdown"
|
|
|
|
|
model_thinking: str = "low"
|
2026-02-19 10:22:27 +08:00
|
|
|
privacy_mode: bool = False
|
|
|
|
|
user_preferences: Optional[UserPreferences] = None
|
2026-05-24 23:30:32 +08:00
|
|
|
model: Optional[str] = None
|
|
|
|
|
temperature: float = 0.7
|
2026-01-18 19:42:58 +08:00
|
|
|
|
2026-02-25 19:00:17 +08:00
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
class ProCompletionRequest(BaseModel):
|
|
|
|
|
prefix: str
|
|
|
|
|
suffix: str
|
|
|
|
|
languageId: str = "markdown"
|
|
|
|
|
instruction: str = ""
|
|
|
|
|
pro_thinking: str = "medium"
|
|
|
|
|
privacy_mode: bool = False
|
|
|
|
|
user_preferences: Optional[UserPreferences] = None
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 19:18:14 +08:00
|
|
|
class WebSearchRequest(BaseModel):
|
|
|
|
|
prefix: str
|
|
|
|
|
suffix: str
|
|
|
|
|
languageId: str = "markdown"
|
|
|
|
|
privacy_mode: bool = False
|
|
|
|
|
user_preferences: Optional[UserPreferences] = None
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 19:00:17 +08:00
|
|
|
class CancelCompletionRequest(BaseModel):
|
|
|
|
|
request_id: str
|
|
|
|
|
reason: str = "abort"
|
|
|
|
|
|
|
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
class OCRRequest(BaseModel):
|
|
|
|
|
image: str
|
|
|
|
|
filename: str = "image.jpg"
|
2026-02-25 19:00:17 +08:00
|
|
|
language: str = "auto"
|
2026-06-18 16:32:31 +08:00
|
|
|
media_type: str = "image"
|
|
|
|
|
mime_type: str | None = None
|
2026-02-14 18:28:37 +08:00
|
|
|
|
|
|
|
|
|
2026-03-10 23:10:11 +08:00
|
|
|
class ConvertRequest(BaseModel):
|
|
|
|
|
file: str
|
|
|
|
|
filename: str = "document.pdf"
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
class CompressRequest(BaseModel):
|
|
|
|
|
content: str
|
|
|
|
|
docType: str = "txt"
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
class TTSJobRequest(BaseModel):
|
|
|
|
|
text: str
|
|
|
|
|
instruct: str = ""
|
|
|
|
|
speaker: str = "Vivian"
|
|
|
|
|
format: str = "wav"
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
class ASRJobRequest(BaseModel):
|
|
|
|
|
audio_base64: str
|
|
|
|
|
language: Optional[str] = "zh-CN"
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 17:18:15 +08:00
|
|
|
class CreateFolderRequest(BaseModel):
|
|
|
|
|
name: str
|
|
|
|
|
parentId: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreateTextFileRequest(BaseModel):
|
|
|
|
|
name: str
|
|
|
|
|
parentId: Optional[str] = None
|
|
|
|
|
content: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateNodeRequest(BaseModel):
|
|
|
|
|
name: Optional[str] = None
|
|
|
|
|
parentId: Optional[str] = None
|
|
|
|
|
content: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
def _preview(text: str, limit: int = 80) -> str:
|
|
|
|
|
value = (text or "").replace("\n", "\\n")
|
|
|
|
|
if len(value) <= limit:
|
|
|
|
|
return value
|
|
|
|
|
return value[:limit] + "..."
|
|
|
|
|
|
2026-02-25 19:00:17 +08:00
|
|
|
|
2026-02-18 08:59:28 +08:00
|
|
|
def get_client_ip(request: Request) -> str:
|
2026-02-25 19:00:17 +08:00
|
|
|
if request.client:
|
|
|
|
|
return request.headers.get("X-Client-IP") or request.client.host
|
|
|
|
|
return request.headers.get("X-Client-IP") or "unknown"
|
|
|
|
|
|
2026-02-18 08:59:28 +08:00
|
|
|
|
2026-05-24 23:30:32 +08:00
|
|
|
def _clamp_temperature(value: float, default: float = 0.7) -> float:
|
|
|
|
|
try:
|
|
|
|
|
numeric = float(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return default
|
|
|
|
|
return max(0.0, min(numeric, 1.2))
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no cover
|
2026-06-08 11:51:39 +08:00
|
|
|
if api_key is not None and api_key != API_KEY:
|
2026-06-06 15:44:00 +08:00
|
|
|
raise HTTPException(status_code=403, detail="Could not validate credentials")
|
|
|
|
|
return api_key
|
2026-02-25 19:00:17 +08:00
|
|
|
|
|
|
|
|
|
2026-06-08 11:51:39 +08:00
|
|
|
@app.middleware("http")
|
|
|
|
|
async def attach_anonymous_session(request: Request, call_next):
|
|
|
|
|
client_ip_hash = stable_hash(get_client_ip(request))
|
|
|
|
|
user_agent_hash = stable_hash(request.headers.get("user-agent", ""))
|
|
|
|
|
store = get_session_store(os.getenv("DATABASE_URL", "").strip() or None)
|
|
|
|
|
session_id = request.cookies.get(config.session_cookie_name)
|
|
|
|
|
session = await asyncio.to_thread(
|
|
|
|
|
store.get_or_create,
|
|
|
|
|
session_id,
|
|
|
|
|
client_ip_hash=client_ip_hash,
|
|
|
|
|
user_agent_hash=user_agent_hash,
|
|
|
|
|
)
|
|
|
|
|
request.state.session = session
|
|
|
|
|
request.state.client_ip_hash = client_ip_hash
|
|
|
|
|
request.state.user_agent_hash = user_agent_hash
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
response.set_cookie(
|
|
|
|
|
key=config.session_cookie_name,
|
|
|
|
|
value=session.session_id,
|
|
|
|
|
max_age=config.session_cookie_max_age,
|
|
|
|
|
httponly=True,
|
|
|
|
|
secure=config.session_cookie_secure,
|
|
|
|
|
samesite=config.session_cookie_samesite,
|
|
|
|
|
domain=config.session_cookie_domain,
|
|
|
|
|
path=config.session_cookie_path,
|
|
|
|
|
)
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
def _serialize_preferences(preferences: UserPreferences | None) -> dict | None:
|
|
|
|
|
if preferences is None:
|
|
|
|
|
return None
|
2026-06-08 11:51:39 +08:00
|
|
|
if hasattr(preferences, "model_dump"):
|
|
|
|
|
return preferences.model_dump()
|
2026-06-06 15:44:00 +08:00
|
|
|
if hasattr(preferences, "dict"):
|
|
|
|
|
return preferences.dict()
|
|
|
|
|
return dict(preferences)
|
2026-02-25 19:00:17 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
def _request_id(request: Request) -> str:
|
|
|
|
|
return request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
2026-02-25 19:00:17 +08:00
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
|
2026-06-08 11:51:39 +08:00
|
|
|
def _request_identity(request: Request) -> RiskIdentity:
|
|
|
|
|
session = getattr(request.state, "session")
|
|
|
|
|
return RiskIdentity(
|
|
|
|
|
request_id=_request_id(request),
|
|
|
|
|
session_hash=session.session_hash,
|
|
|
|
|
ip_hash=request.state.client_ip_hash,
|
|
|
|
|
route=request.url.path,
|
|
|
|
|
method=request.method,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _record_api_audit(
|
|
|
|
|
identity: RiskIdentity,
|
|
|
|
|
*,
|
|
|
|
|
decision: str,
|
|
|
|
|
status_code: int,
|
|
|
|
|
delay_ms: int = 0,
|
|
|
|
|
error_code: str = "",
|
|
|
|
|
metadata: dict | None = None,
|
|
|
|
|
) -> None:
|
|
|
|
|
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
store.record_api_request,
|
|
|
|
|
{
|
|
|
|
|
"request_id": identity.request_id,
|
|
|
|
|
"session_hash": identity.session_hash,
|
|
|
|
|
"ip_hash": identity.ip_hash,
|
|
|
|
|
"route": identity.route,
|
|
|
|
|
"method": identity.method,
|
|
|
|
|
"status_code": status_code,
|
|
|
|
|
"decision": decision,
|
|
|
|
|
"delay_ms": delay_ms,
|
|
|
|
|
"error_code": error_code,
|
|
|
|
|
"metadata": metadata or {},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _risk_json_response(identity: RiskIdentity, decision: RiskDecision) -> JSONResponse:
|
|
|
|
|
payload = {
|
|
|
|
|
"request_id": identity.request_id,
|
|
|
|
|
"error_code": decision.error_code or "request_rejected",
|
|
|
|
|
"message": decision.reason or "request rejected",
|
|
|
|
|
}
|
|
|
|
|
if decision.retry_after_seconds > 0:
|
|
|
|
|
payload["retry_after_seconds"] = decision.retry_after_seconds
|
|
|
|
|
return JSONResponse(payload, status_code=decision.status_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _authorize_request(
|
|
|
|
|
request: Request,
|
|
|
|
|
api_key: str | None = Security(api_key_header),
|
|
|
|
|
) -> dict:
|
|
|
|
|
del request
|
|
|
|
|
if api_key is not None and api_key != API_KEY:
|
|
|
|
|
raise HTTPException(status_code=403, detail="Could not validate credentials")
|
|
|
|
|
return {"api_key_authenticated": bool(api_key == API_KEY)}
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
def _register_handlers() -> None:
|
2026-06-09 19:18:14 +08:00
|
|
|
"""注册所有任务处理器。每次调用都会重新获取当前 manager 实例并强制注册,
|
|
|
|
|
确保 Redis 重连或实例重建后处理器不会丢失。"""
|
2026-06-06 15:44:00 +08:00
|
|
|
global _handlers_registered
|
|
|
|
|
manager = get_job_manager()
|
2026-06-18 16:32:31 +08:00
|
|
|
# 强制清空旧 handlers,避免重复注册累积;测试替身只暴露 register_handler。
|
|
|
|
|
handlers = getattr(manager, "handlers", None)
|
|
|
|
|
if handlers is not None:
|
|
|
|
|
handlers.clear()
|
2026-06-06 15:44:00 +08:00
|
|
|
manager.register_handler("completion", completion_handler)
|
|
|
|
|
manager.register_handler("pro_completion", pro_completion_handler)
|
2026-06-09 19:18:14 +08:00
|
|
|
manager.register_handler("web_search", web_search_handler)
|
2026-06-06 15:44:00 +08:00
|
|
|
manager.register_handler("compress", compress_handler)
|
|
|
|
|
manager.register_handler("ocr", ocr_handler)
|
|
|
|
|
manager.register_handler("convert", convert_handler)
|
|
|
|
|
manager.register_handler("tts", tts_handler)
|
|
|
|
|
manager.register_handler("asr", asr_handler)
|
|
|
|
|
_handlers_registered = True
|
2026-06-09 19:18:14 +08:00
|
|
|
|
|
|
|
|
# 打印注册信息便于调试
|
2026-06-18 16:32:31 +08:00
|
|
|
registered = list(getattr(manager, "handlers", {}).keys())
|
|
|
|
|
logger.info("handlers registered: %s", registered)
|
2026-02-14 18:28:37 +08:00
|
|
|
|
2026-05-24 23:30:32 +08:00
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
def _sse(event: str, data: dict) -> str:
|
|
|
|
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
2026-05-24 23:30:32 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
async def _stream_job(job_id: str):
|
|
|
|
|
_register_handlers()
|
|
|
|
|
manager = get_job_manager()
|
2026-05-24 23:30:32 +08:00
|
|
|
|
|
|
|
|
async def event_stream():
|
2026-06-08 11:51:39 +08:00
|
|
|
event_iterator = manager.stream_events(job_id).__aiter__()
|
|
|
|
|
next_event_task = asyncio.create_task(anext(event_iterator))
|
2026-05-24 23:30:32 +08:00
|
|
|
try:
|
2026-06-08 11:51:39 +08:00
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
event = await asyncio.wait_for(asyncio.shield(next_event_task), timeout=STREAM_HEARTBEAT_SECONDS)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
yield ": keepalive\n\n"
|
|
|
|
|
continue
|
|
|
|
|
except StopAsyncIteration:
|
|
|
|
|
break
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.exception("job stream failed job_id=%s", job_id)
|
|
|
|
|
yield _sse("error", {"job_id": job_id, "error": str(exc)})
|
|
|
|
|
break
|
2026-06-06 15:44:00 +08:00
|
|
|
event_name = event.get("event", "message")
|
|
|
|
|
payload = {k: v for k, v in event.items() if k != "event"}
|
|
|
|
|
yield _sse(event_name, payload)
|
2026-06-08 11:51:39 +08:00
|
|
|
if event_name in {"done", "error", "cancelled"}:
|
|
|
|
|
break
|
|
|
|
|
next_event_task = asyncio.create_task(anext(event_iterator))
|
|
|
|
|
finally:
|
|
|
|
|
if not next_event_task.done():
|
|
|
|
|
next_event_task.cancel()
|
2026-05-24 23:30:32 +08:00
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
event_stream(),
|
2026-06-08 11:51:39 +08:00
|
|
|
media_type="text/event-stream; charset=utf-8",
|
2026-05-24 23:30:32 +08:00
|
|
|
headers={
|
2026-06-08 11:51:39 +08:00
|
|
|
"Cache-Control": "no-cache, no-transform",
|
|
|
|
|
"Connection": "keep-alive",
|
2026-05-24 23:30:32 +08:00
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
async def _queue_job(job_type: str, payload: dict, request_id: str) -> str:
|
|
|
|
|
_register_handlers()
|
|
|
|
|
manager = get_job_manager()
|
|
|
|
|
return await manager.submit(job_type, payload, request_id=request_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _cancel_job(request_id: str, reason: str) -> dict:
|
|
|
|
|
_register_handlers()
|
|
|
|
|
manager = get_job_manager()
|
|
|
|
|
return await manager.cancel(request_id, reason)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _job_status(job_id: str) -> dict | None:
|
|
|
|
|
_register_handlers()
|
|
|
|
|
manager = get_job_manager()
|
|
|
|
|
return await manager.get_status(job_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _queue_load_snapshot() -> dict:
|
|
|
|
|
manager = get_job_manager()
|
|
|
|
|
if isinstance(manager, InMemoryJobManager):
|
|
|
|
|
return {job_type: manager._metrics(job_type) for job_type in manager.queues}
|
|
|
|
|
if isinstance(manager, RedisJobManager):
|
|
|
|
|
return {job_type: await manager._metrics(job_type) for job_type in JOB_TYPES}
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 17:18:15 +08:00
|
|
|
async def _docs_store_call(method_name: str, *args, **kwargs):
|
|
|
|
|
store = get_document_store()
|
|
|
|
|
method = getattr(store, method_name)
|
|
|
|
|
return await asyncio.to_thread(method, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 11:51:39 +08:00
|
|
|
async def _guard_api_request(request: Request, *, scope: str) -> tuple[RiskIdentity, RiskDecision]:
|
|
|
|
|
identity = _request_identity(request)
|
|
|
|
|
controller = get_risk_controller(config)
|
|
|
|
|
decision = await controller.check_api(identity, scope=scope)
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
await _record_api_audit(
|
|
|
|
|
identity,
|
|
|
|
|
decision="rejected",
|
|
|
|
|
status_code=decision.status_code,
|
|
|
|
|
delay_ms=decision.delay_ms,
|
|
|
|
|
error_code=decision.error_code,
|
|
|
|
|
metadata={"scope": scope},
|
|
|
|
|
)
|
|
|
|
|
return identity, decision
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 19:18:14 +08:00
|
|
|
def _estimate_completion_chars(req: CompletionRequest | ProCompletionRequest | WebSearchRequest) -> int:
|
2026-06-08 11:51:39 +08:00
|
|
|
return len(req.prefix or "") + len(req.suffix or "") + len(getattr(req, "instruction", "") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _prepare_llm_payload(
|
|
|
|
|
request: Request,
|
|
|
|
|
*,
|
|
|
|
|
job_type: str,
|
|
|
|
|
request_body: dict,
|
|
|
|
|
raw_size: int,
|
|
|
|
|
token_source_text: str | None = None,
|
|
|
|
|
extra_payload: dict | None = None,
|
|
|
|
|
) -> tuple[RiskIdentity, dict]:
|
|
|
|
|
identity, api_decision = await _guard_api_request(request, scope=job_type)
|
|
|
|
|
if not api_decision.allowed:
|
|
|
|
|
raise RiskRejected(api_decision)
|
|
|
|
|
policy = resolve_llm_policy(job_type, request_body, config)
|
|
|
|
|
if raw_size > policy.max_input_chars:
|
|
|
|
|
await _record_api_audit(
|
|
|
|
|
identity,
|
|
|
|
|
decision="rejected",
|
|
|
|
|
status_code=400,
|
|
|
|
|
error_code="input_too_large",
|
|
|
|
|
metadata={"job_type": job_type, "raw_size": raw_size},
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"输入过长,超过限制 {policy.max_input_chars}")
|
|
|
|
|
estimated_input_tokens = estimate_tokens(token_source_text if token_source_text is not None else json.dumps(request_body, ensure_ascii=False))
|
|
|
|
|
pricing_in = {
|
|
|
|
|
"completion": config.completion_input_cost_per_1k,
|
|
|
|
|
"pro": config.pro_input_cost_per_1k,
|
|
|
|
|
"vision": config.vision_input_cost_per_1k,
|
|
|
|
|
}[policy.profile]
|
|
|
|
|
pricing_out = {
|
|
|
|
|
"completion": config.completion_output_cost_per_1k,
|
|
|
|
|
"pro": config.pro_output_cost_per_1k,
|
|
|
|
|
"vision": config.vision_output_cost_per_1k,
|
|
|
|
|
}[policy.profile]
|
|
|
|
|
estimated_cost = round(
|
|
|
|
|
(estimated_input_tokens / 1000.0) * pricing_in
|
|
|
|
|
+ (policy.max_output_tokens / 1000.0) * pricing_out,
|
|
|
|
|
8,
|
|
|
|
|
)
|
|
|
|
|
controller = get_risk_controller(config)
|
|
|
|
|
llm_decision = await controller.check_llm(identity, scope=policy.model, estimated_cost=estimated_cost)
|
|
|
|
|
if not llm_decision.allowed:
|
|
|
|
|
await _record_api_audit(
|
|
|
|
|
identity,
|
|
|
|
|
decision="rejected",
|
|
|
|
|
status_code=llm_decision.status_code,
|
|
|
|
|
delay_ms=llm_decision.delay_ms,
|
|
|
|
|
error_code=llm_decision.error_code,
|
|
|
|
|
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
|
|
|
|
|
)
|
|
|
|
|
raise RiskRejected(llm_decision)
|
|
|
|
|
await controller.reserve_budget(identity, estimated_cost)
|
|
|
|
|
await _record_api_audit(
|
|
|
|
|
identity,
|
|
|
|
|
decision="accepted",
|
|
|
|
|
status_code=202,
|
|
|
|
|
delay_ms=max(api_decision.delay_ms, llm_decision.delay_ms),
|
|
|
|
|
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
|
|
|
|
|
)
|
|
|
|
|
payload = {
|
|
|
|
|
"request_id": identity.request_id,
|
|
|
|
|
"risk": {
|
|
|
|
|
"request_id": identity.request_id,
|
|
|
|
|
"session_hash": identity.session_hash,
|
|
|
|
|
"ip_hash": identity.ip_hash,
|
|
|
|
|
"delay_ms": max(api_decision.delay_ms, llm_decision.delay_ms),
|
|
|
|
|
"estimated_input_tokens": estimated_input_tokens,
|
|
|
|
|
"estimated_cost": estimated_cost,
|
|
|
|
|
"policy": {
|
|
|
|
|
"job_type": policy.job_type,
|
|
|
|
|
"model": policy.model,
|
|
|
|
|
"profile": policy.profile,
|
|
|
|
|
"max_input_chars": policy.max_input_chars,
|
|
|
|
|
"max_output_tokens": policy.max_output_tokens,
|
|
|
|
|
"temperature": policy.temperature,
|
|
|
|
|
"thinking": policy.thinking,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"request": request_body,
|
|
|
|
|
}
|
|
|
|
|
if extra_payload:
|
|
|
|
|
payload.update(extra_payload)
|
|
|
|
|
return identity, payload
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
@app.post("/v1/completions")
|
|
|
|
|
async def create_completion(
|
|
|
|
|
request: Request,
|
|
|
|
|
req: CompletionRequest,
|
2026-06-08 11:51:39 +08:00
|
|
|
auth: dict = Security(_authorize_request),
|
2026-06-06 15:44:00 +08:00
|
|
|
):
|
2026-06-08 11:51:39 +08:00
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
location = ""
|
|
|
|
|
if not req.privacy_mode: # pragma: no cover
|
|
|
|
|
location = get_ip_location_text(get_client_ip(request))
|
2026-06-08 11:51:39 +08:00
|
|
|
body = {
|
|
|
|
|
"prefix": req.prefix,
|
|
|
|
|
"suffix": req.suffix,
|
|
|
|
|
"languageId": req.languageId,
|
|
|
|
|
"model_thinking": req.model_thinking,
|
|
|
|
|
"privacy_mode": req.privacy_mode,
|
|
|
|
|
"user_preferences": _serialize_preferences(req.user_preferences),
|
|
|
|
|
"temperature": _clamp_temperature(req.temperature, 0.7),
|
2026-06-06 15:44:00 +08:00
|
|
|
}
|
|
|
|
|
try:
|
2026-06-08 11:51:39 +08:00
|
|
|
identity, payload = await _prepare_llm_payload(
|
|
|
|
|
request,
|
|
|
|
|
job_type="completion",
|
|
|
|
|
request_body=body,
|
|
|
|
|
raw_size=_estimate_completion_chars(req),
|
|
|
|
|
token_source_text=f"{req.prefix}\n{req.suffix}",
|
|
|
|
|
extra_payload={"location": location},
|
|
|
|
|
)
|
|
|
|
|
job_id = await _queue_job("completion", payload, identity.request_id)
|
|
|
|
|
except RiskRejected as exc:
|
|
|
|
|
return _risk_json_response(_request_identity(request), exc.decision)
|
2026-06-06 15:44:00 +08:00
|
|
|
except QueueFullError as exc:
|
2026-06-08 11:51:39 +08:00
|
|
|
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
|
2026-06-06 15:44:00 +08:00
|
|
|
except JobSystemError as exc:
|
2026-06-08 11:51:39 +08:00
|
|
|
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
|
2026-06-06 15:44:00 +08:00
|
|
|
return await _stream_job(job_id)
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 19:00:17 +08:00
|
|
|
@app.post("/v1/completions/cancel")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def cancel_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
return await _cancel_job(req.request_id or "", req.reason)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/pro/completions")
|
|
|
|
|
async def create_pro_completion(
|
|
|
|
|
request: Request,
|
|
|
|
|
req: ProCompletionRequest,
|
2026-06-08 11:51:39 +08:00
|
|
|
auth: dict = Security(_authorize_request),
|
2026-06-06 15:44:00 +08:00
|
|
|
):
|
2026-06-08 11:51:39 +08:00
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
location = ""
|
|
|
|
|
if not req.privacy_mode: # pragma: no cover
|
|
|
|
|
location = get_ip_location_text(get_client_ip(request))
|
2026-06-08 11:51:39 +08:00
|
|
|
body = {
|
|
|
|
|
"prefix": req.prefix,
|
|
|
|
|
"suffix": req.suffix,
|
|
|
|
|
"languageId": req.languageId,
|
|
|
|
|
"instruction": req.instruction,
|
|
|
|
|
"pro_thinking": req.pro_thinking,
|
|
|
|
|
"privacy_mode": req.privacy_mode,
|
|
|
|
|
"user_preferences": _serialize_preferences(req.user_preferences),
|
2026-06-06 15:44:00 +08:00
|
|
|
}
|
|
|
|
|
try:
|
2026-06-08 11:51:39 +08:00
|
|
|
identity, payload = await _prepare_llm_payload(
|
|
|
|
|
request,
|
|
|
|
|
job_type="pro_completion",
|
|
|
|
|
request_body=body,
|
|
|
|
|
raw_size=_estimate_completion_chars(req),
|
|
|
|
|
token_source_text=f"{req.prefix}\n{req.suffix}\n{req.instruction}",
|
|
|
|
|
extra_payload={"location": location},
|
|
|
|
|
)
|
|
|
|
|
job_id = await _queue_job("pro_completion", payload, identity.request_id)
|
|
|
|
|
except RiskRejected as exc:
|
|
|
|
|
return _risk_json_response(_request_identity(request), exc.decision)
|
2026-06-06 15:44:00 +08:00
|
|
|
except QueueFullError as exc:
|
2026-06-08 11:51:39 +08:00
|
|
|
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
|
2026-06-06 15:44:00 +08:00
|
|
|
except JobSystemError as exc:
|
2026-06-08 11:51:39 +08:00
|
|
|
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
|
2026-06-06 15:44:00 +08:00
|
|
|
return await _stream_job(job_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/pro/completions/cancel")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def cancel_pro_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
return await _cancel_job(req.request_id or "", req.reason)
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 19:18:14 +08:00
|
|
|
@app.post("/v1/web-search")
|
|
|
|
|
async def create_web_search(
|
|
|
|
|
request: Request,
|
|
|
|
|
req: WebSearchRequest,
|
|
|
|
|
auth: dict = Security(_authorize_request),
|
|
|
|
|
):
|
|
|
|
|
del auth
|
|
|
|
|
body = {
|
|
|
|
|
"prefix": req.prefix,
|
|
|
|
|
"suffix": req.suffix,
|
|
|
|
|
"languageId": req.languageId,
|
|
|
|
|
"privacy_mode": req.privacy_mode,
|
|
|
|
|
"user_preferences": _serialize_preferences(req.user_preferences),
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
identity, payload = await _prepare_llm_payload(
|
|
|
|
|
request,
|
|
|
|
|
job_type="web_search",
|
|
|
|
|
request_body=body,
|
|
|
|
|
raw_size=_estimate_completion_chars(req),
|
|
|
|
|
token_source_text=f"{req.prefix}\n{req.suffix}",
|
|
|
|
|
)
|
|
|
|
|
payload["created_at"] = datetime.utcnow().isoformat()
|
|
|
|
|
job_id = await _queue_job("web_search", payload, identity.request_id)
|
|
|
|
|
except RiskRejected as exc:
|
|
|
|
|
return _risk_json_response(_request_identity(request), exc.decision)
|
|
|
|
|
except QueueFullError as exc:
|
|
|
|
|
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
|
|
|
|
|
except JobSystemError as exc:
|
|
|
|
|
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
|
|
|
|
|
return await _stream_job(job_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/web-search/cancel")
|
|
|
|
|
async def cancel_web_search(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
return await _cancel_job(req.request_id or "", req.reason)
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
@app.get("/v1/pro/completions/status/{request_id}")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def get_pro_completion_status(request_id: str, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
state = await _job_status(request_id)
|
|
|
|
|
if state is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="PRO request not found")
|
|
|
|
|
return state
|
2026-02-25 19:00:17 +08:00
|
|
|
|
2026-02-14 18:28:37 +08:00
|
|
|
|
|
|
|
|
@app.post("/v1/ocr")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def ocr_image(request: Request, req: OCRRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-02-14 18:28:37 +08:00
|
|
|
try:
|
2026-06-06 15:44:00 +08:00
|
|
|
image_bytes = base64.b64decode(req.image)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return JSONResponse({"error": str(exc)}, status_code=500)
|
2026-06-08 11:51:39 +08:00
|
|
|
if len(image_bytes) > config.ocr_max_input_bytes:
|
2026-06-18 16:32:31 +08:00
|
|
|
return JSONResponse({"error": "文件过大,无法执行 OCR/视频解析"}, status_code=400)
|
2026-06-06 15:44:00 +08:00
|
|
|
input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
|
|
|
|
|
try:
|
2026-06-08 11:51:39 +08:00
|
|
|
identity, payload = await _prepare_llm_payload(
|
|
|
|
|
request,
|
|
|
|
|
job_type="ocr",
|
2026-06-18 16:32:31 +08:00
|
|
|
request_body={
|
|
|
|
|
"filename": req.filename,
|
|
|
|
|
"language": req.language,
|
|
|
|
|
"media_type": req.media_type,
|
|
|
|
|
"mime_type": req.mime_type,
|
|
|
|
|
"image_bytes": len(image_bytes),
|
|
|
|
|
},
|
2026-06-08 11:51:39 +08:00
|
|
|
raw_size=len(image_bytes),
|
2026-06-18 16:32:31 +08:00
|
|
|
token_source_text=f"{req.filename}:{req.media_type}:{req.mime_type}:{len(image_bytes)}:{req.language}",
|
|
|
|
|
extra_payload={
|
|
|
|
|
"input_path": input_path,
|
|
|
|
|
"filename": req.filename,
|
|
|
|
|
"language": req.language,
|
|
|
|
|
"media_type": req.media_type,
|
|
|
|
|
"mime_type": req.mime_type,
|
|
|
|
|
},
|
2026-06-08 11:51:39 +08:00
|
|
|
)
|
|
|
|
|
job_id = await _queue_job("ocr", payload, identity.request_id)
|
|
|
|
|
except RiskRejected as exc:
|
|
|
|
|
return _risk_json_response(_request_identity(request), exc.decision)
|
2026-06-06 15:44:00 +08:00
|
|
|
except Exception:
|
|
|
|
|
if os.path.exists(input_path):
|
|
|
|
|
os.unlink(input_path)
|
|
|
|
|
raise
|
|
|
|
|
return await _stream_job(job_id)
|
2026-01-25 13:29:11 +08:00
|
|
|
|
2026-02-25 19:00:17 +08:00
|
|
|
|
2026-03-10 23:10:11 +08:00
|
|
|
@app.post("/v1/convert")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def convert_to_markdown(request: Request, req: ConvertRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="convert")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
|
|
|
|
request_id = identity.request_id
|
2026-06-06 15:44:00 +08:00
|
|
|
ext = os.path.splitext(req.filename)[1].lower()
|
|
|
|
|
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
|
|
|
|
return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
|
2026-03-10 23:10:11 +08:00
|
|
|
try:
|
2026-06-06 15:44:00 +08:00
|
|
|
file_bytes = base64.b64decode(req.file)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
|
|
|
input_path = persist_temp_input(file_bytes, ext or ".bin")
|
|
|
|
|
try:
|
|
|
|
|
job_id = await _queue_job("convert", {
|
|
|
|
|
"request_id": request_id,
|
|
|
|
|
"input_path": input_path,
|
|
|
|
|
"filename": req.filename,
|
|
|
|
|
}, request_id)
|
|
|
|
|
except Exception:
|
|
|
|
|
if os.path.exists(input_path):
|
|
|
|
|
os.unlink(input_path)
|
|
|
|
|
raise
|
|
|
|
|
return await _stream_job(job_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/compress/submit")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def submit_compress(request: Request, req: CompressRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
content = req.content or ""
|
|
|
|
|
if not content.strip():
|
|
|
|
|
raise HTTPException(status_code=400, detail="文档内容为空,无法压缩")
|
|
|
|
|
if len(content) > DOC_COMPRESS_CONTEXT_LIMIT:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"文档内容过长({len(content)} 字符),超过限制 {DOC_COMPRESS_CONTEXT_LIMIT},无法压缩",
|
2026-03-10 23:10:11 +08:00
|
|
|
)
|
2026-06-08 11:51:39 +08:00
|
|
|
try:
|
|
|
|
|
identity, payload = await _prepare_llm_payload(
|
|
|
|
|
request,
|
|
|
|
|
job_type="compress",
|
|
|
|
|
request_body={"content_length": len(content), "docType": req.docType or "txt"},
|
|
|
|
|
raw_size=len(content),
|
|
|
|
|
token_source_text=content,
|
|
|
|
|
extra_payload={"content": content, "docType": req.docType or "txt"},
|
|
|
|
|
)
|
|
|
|
|
await _queue_job("compress", payload, identity.request_id)
|
|
|
|
|
return {"task_id": identity.request_id, "status": "queued"}
|
|
|
|
|
except RiskRejected as exc:
|
|
|
|
|
return _risk_json_response(_request_identity(request), exc.decision)
|
2026-06-06 15:44:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/v1/compress/status")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def get_compress_status(task_id: str, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
if not task_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="缺少 task_id 参数")
|
|
|
|
|
state = await _job_status(task_id)
|
|
|
|
|
if state is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="任务不存在或已过期")
|
|
|
|
|
if state["status"] == "completed":
|
|
|
|
|
result = state.get("result") or {}
|
|
|
|
|
return {"task_id": task_id, "status": "completed", "content": result.get("content", "")}
|
|
|
|
|
if state["status"] == "failed":
|
|
|
|
|
return {"task_id": task_id, "status": "error", "message": state.get("error") or ""}
|
|
|
|
|
if state["status"] == "cancelled":
|
|
|
|
|
return {"task_id": task_id, "status": "cancelled"}
|
|
|
|
|
return {
|
|
|
|
|
"task_id": task_id,
|
|
|
|
|
"status": "processing" if state["status"] == "running" else "queued",
|
|
|
|
|
"busy_level": state.get("busy_level"),
|
|
|
|
|
"queued_count": state.get("queued_count"),
|
|
|
|
|
"running_count": state.get("running_count"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/tts-asr/tts")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def queue_tts(req: TTSJobRequest, request: Request, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
request_id = _request_id(request)
|
|
|
|
|
job_id = await _queue_job("tts", {
|
|
|
|
|
"request_id": request_id,
|
|
|
|
|
"text": req.text,
|
|
|
|
|
"instruct": req.instruct,
|
|
|
|
|
"speaker": req.speaker,
|
|
|
|
|
"format": req.format,
|
|
|
|
|
}, request_id)
|
|
|
|
|
return await _stream_job(job_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/tts-asr/asr")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
request_id = _request_id(request)
|
|
|
|
|
try:
|
|
|
|
|
audio_bytes = base64.b64decode(req.audio_base64)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
|
|
|
input_path = persist_temp_input(audio_bytes, ".wav")
|
|
|
|
|
try:
|
|
|
|
|
job_id = await _queue_job("asr", {
|
|
|
|
|
"request_id": request_id,
|
|
|
|
|
"input_path": input_path,
|
|
|
|
|
"language": req.language or "zh-CN",
|
|
|
|
|
}, request_id)
|
|
|
|
|
except Exception:
|
|
|
|
|
if os.path.exists(input_path):
|
|
|
|
|
os.unlink(input_path)
|
|
|
|
|
raise
|
|
|
|
|
return await _stream_job(job_id)
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
@app.post("/v1/jobs/{job_id}/cancel")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def cancel_job(job_id: str, req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
return await _cancel_job(req.request_id or job_id, req.reason)
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
@app.get("/v1/jobs/{job_id}/status")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def get_job_status(job_id: str, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
state = await _job_status(job_id)
|
|
|
|
|
if state is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="job not found")
|
|
|
|
|
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/v1/jobs/load")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def get_job_load(auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
2026-06-06 15:44:00 +08:00
|
|
|
return {"queues": await _queue_load_snapshot()}
|
2026-04-04 23:56:18 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 17:18:15 +08:00
|
|
|
@app.get("/v1/docs/nodes")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def list_docs_nodes(request: Request, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_list")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
try:
|
|
|
|
|
return {"nodes": await _docs_store_call("list_nodes")}
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/docs/folders")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def create_docs_folder(request: Request, req: CreateFolderRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_write")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
if not (req.name or "").strip():
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件夹名称不能为空")
|
|
|
|
|
try:
|
|
|
|
|
node = await _docs_store_call("create_folder", req.name.strip(), req.parentId)
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
return {"node": node}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/docs/files/text")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def create_docs_text_file(request: Request, req: CreateTextFileRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_write")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
if not (req.name or "").strip():
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件名称不能为空")
|
|
|
|
|
try:
|
|
|
|
|
node = await _docs_store_call("create_text_file", req.name.strip(), req.parentId, req.content or "")
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
return {"node": node}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/v1/docs/files/upload")
|
|
|
|
|
async def upload_docs_file(
|
2026-06-08 11:51:39 +08:00
|
|
|
request: Request,
|
2026-06-06 17:18:15 +08:00
|
|
|
file: UploadFile = File(...),
|
|
|
|
|
parent_id: Optional[str] = Form(default=None),
|
2026-06-08 11:51:39 +08:00
|
|
|
auth: dict = Security(_authorize_request),
|
2026-06-06 17:18:15 +08:00
|
|
|
):
|
2026-06-08 11:51:39 +08:00
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_write")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
filename = (file.filename or "").strip()
|
|
|
|
|
if not filename:
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件名称不能为空")
|
|
|
|
|
raw_bytes = await file.read()
|
|
|
|
|
try:
|
|
|
|
|
node = await _docs_store_call("upload_file", filename, parent_id, raw_bytes, file.content_type or "")
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
return {"node": node}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/v1/docs/nodes/{node_id}")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def update_docs_node(request: Request, node_id: str, req: UpdateNodeRequest, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_write")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
fields_set = req.model_fields_set if hasattr(req, "model_fields_set") else getattr(req, "__fields_set__", set())
|
|
|
|
|
if not fields_set:
|
|
|
|
|
raise HTTPException(status_code=400, detail="缺少更新内容")
|
|
|
|
|
update_kwargs = {}
|
|
|
|
|
if "name" in fields_set:
|
|
|
|
|
next_name = req.name.strip() if isinstance(req.name, str) else ""
|
|
|
|
|
if not next_name:
|
|
|
|
|
raise HTTPException(status_code=400, detail="名称不能为空")
|
|
|
|
|
update_kwargs["name"] = next_name
|
|
|
|
|
if "parentId" in fields_set:
|
|
|
|
|
update_kwargs["parent_id"] = req.parentId
|
|
|
|
|
if "content" in fields_set:
|
|
|
|
|
update_kwargs["content"] = req.content or ""
|
|
|
|
|
try:
|
|
|
|
|
node = await _docs_store_call("update_node", node_id, **update_kwargs)
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="节点不存在") from exc
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
return {"node": node}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.put("/v1/docs/files/{node_id}/blob")
|
|
|
|
|
async def replace_docs_blob(
|
2026-06-08 11:51:39 +08:00
|
|
|
request: Request,
|
2026-06-06 17:18:15 +08:00
|
|
|
node_id: str,
|
|
|
|
|
file: UploadFile = File(...),
|
2026-06-08 11:51:39 +08:00
|
|
|
auth: dict = Security(_authorize_request),
|
2026-06-06 17:18:15 +08:00
|
|
|
):
|
2026-06-08 11:51:39 +08:00
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_write")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
filename = (file.filename or "").strip()
|
|
|
|
|
if not filename:
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件名称不能为空")
|
|
|
|
|
raw_bytes = await file.read()
|
|
|
|
|
try:
|
|
|
|
|
node = await _docs_store_call("replace_blob", node_id, filename, raw_bytes, file.content_type or "")
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="节点不存在") from exc
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
return {"node": node}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/v1/docs/nodes/{node_id}")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def delete_docs_node(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_write")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
try:
|
|
|
|
|
await _docs_store_call("delete_node", node_id)
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/v1/docs/files/{node_id}/blob")
|
2026-06-08 11:51:39 +08:00
|
|
|
async def download_docs_blob(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
|
|
|
|
|
del auth
|
|
|
|
|
identity, decision = await _guard_api_request(request, scope="docs_blob")
|
|
|
|
|
if not decision.allowed:
|
|
|
|
|
return _risk_json_response(identity, decision)
|
2026-06-06 17:18:15 +08:00
|
|
|
try:
|
|
|
|
|
payload = await _docs_store_call("get_blob", node_id)
|
|
|
|
|
except FileNotFoundError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail="文件不存在") from exc
|
|
|
|
|
except RuntimeError as exc:
|
|
|
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
|
|
|
headers = {
|
|
|
|
|
"Content-Disposition": f'inline; filename="{payload.filename}"',
|
|
|
|
|
}
|
|
|
|
|
return Response(content=payload.content, media_type=payload.mime_type, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 10:16:16 +08:00
|
|
|
def _register_tts_asr_routes():
|
2026-05-24 23:30:32 +08:00
|
|
|
try:
|
|
|
|
|
from tts_asr import register_tts_asr_routes
|
|
|
|
|
except ModuleNotFoundError as exc:
|
|
|
|
|
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
|
|
|
|
|
return
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
2026-06-06 15:44:00 +08:00
|
|
|
register_tts_asr_routes(app, include_generation_routes=False)
|
2026-05-24 23:30:32 +08:00
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("Failed to register TTS/ASR routes: %s", exc)
|
2026-04-05 10:16:16 +08:00
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
|
2026-04-05 10:16:16 +08:00
|
|
|
_register_tts_asr_routes()
|
2026-04-04 20:05:40 +08:00
|
|
|
|
2026-06-06 15:44:00 +08:00
|
|
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
|
|
|
async def _shutdown_job_manager(): # pragma: no cover
|
|
|
|
|
manager = get_job_manager()
|
|
|
|
|
close = getattr(manager, "close", None)
|
|
|
|
|
if close is not None:
|
|
|
|
|
await close()
|
|
|
|
|
|
2026-04-07 12:43:22 +08:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8001)
|