study / api /third_party_images.py
xiaolongmr
1.07
59af925
Raw History Blame Contribute Delete
13.2 kB
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError
from starlette.datastructures import UploadFile
from api.support import require_admin, require_identity
from services.image_api_adapter import (
ADAPTER_MODEL,
adapter_system_prompt,
adapter_user_prompt,
document_to_text,
infer_document_auth_type,
normalize_adapter_result,
parse_model_json,
redact_document_secrets,
)
from services.log_service import LoggedCall
from services.protocol import openai_v1_chat_complete
from services.third_party_image_service import third_party_image_service
class ThirdPartyImageGroupsRequest(BaseModel):
groups: list[dict[str, Any]] = Field(default_factory=list)
settings: dict[str, Any] | None = None
class ThirdPartyImageTestRequest(BaseModel):
connection_id: str = ""
connection: dict[str, Any] | None = None
class ThirdPartyImageAdaptDocumentRequest(BaseModel):
document: str = Field(..., min_length=1, max_length=650_000)
filename: str = Field(default="", max_length=300)
base_url: str = Field(default="", max_length=2000)
class ThirdPartyImageOnlineCallRequest(BaseModel):
connection: dict[str, Any] | None = None
adapter_template: dict[str, Any] | None = None
request_body: dict[str, Any] = Field(default_factory=dict)
operation: str = "generate"
call_mode: str = "custom"
class ThirdPartyImageOnlineModelsRequest(BaseModel):
connection: dict[str, Any] | None = None
adapter_template: dict[str, Any] | None = None
MAX_ONLINE_CALL_IMAGES = 8
MAX_ONLINE_CALL_IMAGE_BYTES = 50 * 1024 * 1024
def _json_object_field(form: Any, name: str, required: bool = False):
raw = form.get(name)
if raw in (None, "") and not required:
return None
if not isinstance(raw, str):
raise HTTPException(status_code=400, detail={"error": f"{name} 必须是 JSON 对象"})
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail={"error": f"{name} 不是有效的 JSON"}) from exc
if not isinstance(value, dict):
raise HTTPException(status_code=400, detail={"error": f"{name} 必须是 JSON 对象"})
return value
async def _parse_online_call_request(
request: Request,
) -> tuple[ThirdPartyImageOnlineCallRequest, list[tuple[bytes, str, str]]]:
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "multipart/form-data":
try:
return ThirdPartyImageOnlineCallRequest.model_validate(await request.json()), []
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
raise HTTPException(status_code=400, detail={"error": "在线调用参数格式无效"}) from exc
form = await request.form()
body = ThirdPartyImageOnlineCallRequest(
connection=_json_object_field(form, "connection"),
adapter_template=_json_object_field(form, "adapter_template"),
request_body=_json_object_field(form, "request_body", required=True) or {},
operation=str(form.get("operation") or "generate"),
call_mode=str(form.get("call_mode") or "custom"),
)
uploads = [value for key, value in form.multi_items() if key == "images" and isinstance(value, UploadFile)]
if len(uploads) > MAX_ONLINE_CALL_IMAGES:
raise HTTPException(status_code=400, detail={"error": "参考图最多上传 8 张"})
images: list[tuple[bytes, str, str]] = []
total_bytes = 0
for index, upload in enumerate(uploads):
mime_type = str(upload.content_type or "application/octet-stream").lower()
if not mime_type.startswith("image/"):
raise HTTPException(status_code=400, detail={"error": f"{upload.filename or '参考图'} 不是图片文件"})
data = await upload.read(MAX_ONLINE_CALL_IMAGE_BYTES + 1)
total_bytes += len(data)
if not data:
raise HTTPException(status_code=400, detail={"error": f"{upload.filename or '参考图'} 内容为空"})
if len(data) > MAX_ONLINE_CALL_IMAGE_BYTES or total_bytes > MAX_ONLINE_CALL_IMAGE_BYTES:
raise HTTPException(status_code=400, detail={"error": "参考图总大小不能超过 50 MB"})
images.append((data, upload.filename or f"reference_{index + 1}.png", mime_type))
return body, images
def _chat_completion_text(value: object) -> str:
if not isinstance(value, dict):
return ""
choices = value.get("choices")
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
return ""
message = choices[0].get("message")
if not isinstance(message, dict):
return ""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if not isinstance(content, list):
return ""
return "".join(
str(item.get("text") or "")
for item in content
if isinstance(item, dict) and str(item.get("type") or "") in {"text", "output_text"}
).strip()
def create_router() -> APIRouter:
router = APIRouter()
@router.get("/api/third-party-images/groups")
async def list_groups(authorization: str | None = Header(default=None)):
require_admin(authorization)
return {
"groups": third_party_image_service.list_groups(),
"settings": third_party_image_service.get_settings(),
}
@router.post("/api/third-party-images/groups")
async def save_groups(body: ThirdPartyImageGroupsRequest, authorization: str | None = Header(default=None)):
require_admin(authorization)
try:
groups = await run_in_threadpool(third_party_image_service.save_groups, body.groups, body.settings)
return {"groups": groups, "settings": third_party_image_service.get_settings()}
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
@router.post("/api/third-party-images/adapt-document")
async def adapt_document(
body: ThirdPartyImageAdaptDocumentRequest,
authorization: str | None = Header(default=None),
):
identity = require_admin(authorization)
try:
readable = document_to_text(body.document, body.filename)
redacted = redact_document_secrets(readable)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
auth_hint = infer_document_auth_type(readable)
system_prompt = adapter_system_prompt()
user_prompt = adapter_user_prompt(
redacted,
filename=body.filename,
base_url=body.base_url,
auth_hint=auth_hint,
)
payload = {
"model": ADAPTER_MODEL,
"stream": False,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
call = LoggedCall(
identity,
"/api/third-party-images/adapt-document",
ADAPTER_MODEL,
"生图 API 文档适配",
request_text=f"导入文档:{body.filename or '未命名文档'}",
)
result = await call.run(openai_v1_chat_complete.handle, payload)
model_text = _chat_completion_text(result)
if not model_text:
raise HTTPException(status_code=502, detail={"error": "站内 AI 未返回调用模板"})
try:
normalized = normalize_adapter_result(
parse_model_json(model_text),
base_url=body.base_url,
auth_hint=auth_hint,
)
except ValueError as exc:
raise HTTPException(status_code=502, detail={"error": f"AI 生成的调用模板未通过安全校验:{exc}"}) from exc
return {
**normalized,
"model": ADAPTER_MODEL,
"document_redacted": redacted != readable,
"auth_hint": auth_hint,
}
@router.post("/api/third-party-images/groups/{group_id}/connections/{connection_id}/online-call")
async def online_call_adapter(
group_id: str,
connection_id: str,
request: Request,
authorization: str | None = Header(default=None),
):
require_admin(authorization)
body, reference_images = await _parse_online_call_request(request)
try:
if body.call_mode == "openai":
return await run_in_threadpool(
third_party_image_service.online_test_openai,
group_id,
connection_id,
connection_override=body.connection,
request_body=body.request_body,
reference_images=reference_images,
operation=body.operation,
)
return await run_in_threadpool(
third_party_image_service.online_test_adapter,
group_id,
connection_id,
connection_override=body.connection,
adapter_template=body.adapter_template,
request_body=body.request_body,
reference_images=reference_images,
operation=body.operation,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
@router.post("/api/third-party-images/groups/{group_id}/connections/{connection_id}/adapter-models")
async def online_adapter_models(
group_id: str,
connection_id: str,
body: ThirdPartyImageOnlineModelsRequest,
authorization: str | None = Header(default=None),
):
require_admin(authorization)
try:
return await run_in_threadpool(
third_party_image_service.online_adapter_models,
group_id,
connection_id,
connection_override=body.connection,
adapter_template=body.adapter_template,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
@router.post("/api/third-party-images/groups/{group_id}/sync-models")
async def sync_models(group_id: str, authorization: str | None = Header(default=None)):
require_admin(authorization)
try:
return await run_in_threadpool(third_party_image_service.sync_models, group_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail={"error": str(exc)}) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
@router.post("/api/third-party-images/groups/{group_id}/connections/{connection_id}/sync-models")
async def sync_connection_models(
group_id: str,
connection_id: str,
body: ThirdPartyImageTestRequest,
authorization: str | None = Header(default=None),
):
require_admin(authorization)
try:
return await run_in_threadpool(
third_party_image_service.sync_connection_models,
group_id,
connection_id,
body.connection,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail={"error": str(exc)}) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
@router.post("/api/third-party-images/groups/{group_id}/test")
async def test_connection(
group_id: str,
body: ThirdPartyImageTestRequest,
authorization: str | None = Header(default=None),
):
require_admin(authorization)
try:
return {
"result": await run_in_threadpool(
third_party_image_service.test_connection,
group_id,
body.connection_id,
body.connection,
)
}
except ValueError as exc:
raise HTTPException(status_code=404, detail={"error": str(exc)}) from exc
@router.get("/api/third-party-images/options")
async def list_user_options(authorization: str | None = Header(default=None)):
identity = require_identity(authorization)
# 生图页的模型下拉:只要**能生图**的第三方模型。
# 第三方接入里可能挂着文本模型(capabilities 只有 chat),它们不能生图,
# 放进来只会让用户在生图页选到一个必然失败的模型。
return {
"models": third_party_image_service.public_models_for_identity(identity, image_only=True)
}
return router