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