from __future__ import annotations import mimetypes from decimal import Decimal, ROUND_HALF_UP from typing import Any from fastapi import APIRouter, Header, HTTPException, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel, ConfigDict, Field from curl_cffi import requests as curl_requests from api.image_inputs import parse_image_edit_request, read_image_sources from api.support import require_identity, resolve_image_base_url from services.auth_service import DAILY_GIFT_ACCOUNT_POOL_MODEL_ID, auth_service from services.chat_model_service import resolve_chat_models from services.image_model_service import resolve_image_models from services.system_model_service import resolve_system_models from services.content_filter import check_request, request_text from services.config import config from services.editable_file_task_service import editable_file_task_service from services.image_annotation_service import identify_image_region from services.log_service import LoggedCall from services.pool_pricing_service import pool_pricing_service from services.skill_service import skill_service from services.protocol import ( anthropic_v1_messages, openai_v1_chat_complete, openai_v1_image_edit, openai_v1_image_generations, openai_v1_models, openai_v1_response, openai_search, ) from services.third_party_image_service import third_party_image_service from utils.helper import has_response_image_generation_tool, is_image_chat_request, parse_image_count class ImageGenerationRequest(BaseModel): prompt: str = Field(..., min_length=1) model: str = "gpt-image-2" n: int = Field(default=1, ge=1, le=4) size: str | None = None quality: str | None = None resolution: str | None = None image_resolution: str | None = None third_party_image_group_id: str | None = None response_format: str = "url" account_pool_strategy: str = "own_first" history_disabled: bool = True stream: bool | None = None class ChatCompletionRequest(BaseModel): model_config = ConfigDict(extra="allow") model: str | None = None prompt: str | None = None n: int | None = None stream: bool | None = None modalities: list[str] | None = None messages: list[dict[str, object]] | None = None class ResponseCreateRequest(BaseModel): model_config = ConfigDict(extra="allow") model: str | None = None input: object | None = None tools: list[dict[str, object]] | None = None tool_choice: object | None = None stream: bool | None = None class AnthropicMessageRequest(BaseModel): model_config = ConfigDict(extra="allow") model: str | None = None messages: list[dict[str, object]] | None = None system: object | None = None stream: bool | None = None class PromptOptimizationRequest(BaseModel): prompt: str = Field(..., min_length=1, max_length=30000) class ReversePromptRequest(BaseModel): image: str = Field(..., min_length=1, max_length=40_000_000) class ImageRegionBounds(BaseModel): x: float = Field(..., ge=0, le=100) y: float = Field(..., ge=0, le=100) width: float = Field(..., gt=0, le=100) height: float = Field(..., gt=0, le=100) class ImageRegionRecognitionRequest(BaseModel): image: str = Field(..., min_length=1, max_length=40_000_000) bounds: ImageRegionBounds class SearchRequest(BaseModel): prompt: str = Field(..., min_length=1) class EditableFileTaskRequest(BaseModel): prompt: str = "" base64_images: list[str] = Field(default_factory=list) client_task_id: str | None = None async def filter_or_log(call: LoggedCall, text: str) -> None: try: await run_in_threadpool(check_request, text) except HTTPException as exc: call.log("调用失败", status="failed", error=str(exc.detail)) raise def _to_positive_image_count(value: object, default: int = 1) -> int: try: return max(1, int(value or default)) except (TypeError, ValueError): return default def _credit_amount(value: object) -> int | float: try: amount = max(Decimal("0"), Decimal(str(value or 0))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) except Exception: amount = Decimal("0") return int(amount) if amount == amount.to_integral_value() else float(amount) def _third_party_activity_quote(identity: dict[str, object], quantity: int, *, group_id: str, base_unit_price: object) -> dict[str, object]: pricing_config = config.normal_user_third_party_image_pricing if str(identity.get("role") or "").strip().lower() != "normal" or not bool(pricing_config.get("enabled")): base = _credit_amount(base_unit_price) return { "applies": False, "target_type": "third_party_group", "third_party_group_id": group_id, "base_unit_price": base, "pay_percentage": 100, "unit_price": base, "quantity": quantity, "estimated_total": _credit_amount(float(base) * quantity), "rule_id": "", "rule_name": "", } return pool_pricing_service.quote( identity, quantity=quantity, pricing_config=pricing_config, target_type="third_party_group", third_party_group_id=group_id, base_unit_price=base_unit_price, ) def _third_party_wallet_remaining(identity: dict[str, object], group_id: str, model_id: str) -> int | float: try: summary = auth_service.credit_summary(identity) except Exception: return 0 wallets = summary.get("third_party_image_wallets") if isinstance(summary, dict) else [] wallet_key = f"{group_id}:{model_id}" wallet_items = wallets if isinstance(wallets, list) else [] balances: list[int | float] = [] for wallet in wallet_items: if not isinstance(wallet, dict): continue if str(wallet.get("id") or "") == wallet_key or ( str(wallet.get("group_id") or "") == group_id and str(wallet.get("model_id") or "").strip() == model_id ): try: balances.append(_credit_amount(wallet.get("remaining"))) except (TypeError, ValueError): balances.append(0) return max(balances) if balances else 0 def _count_data_images(value: object) -> int: if not isinstance(value, list): return 0 return sum( 1 for item in value if isinstance(item, dict) and (str(item.get("b64_json") or "").strip() or str(item.get("url") or "").strip()) ) def _count_image_generation_items(value: object) -> int: if not isinstance(value, list): return 0 count = 0 for item in value: if not isinstance(item, dict): continue if str(item.get("type") or "") == "image_generation_call" and str(item.get("result") or "").strip(): count += 1 return count def _count_data_url_images(text: object) -> int: return str(text or "").count("data:image/") def _count_successful_images(value: object) -> int: if not isinstance(value, dict): return 0 count = _count_data_images(value.get("data")) if count: return count output = value.get("output") count = _count_image_generation_items(output) if count: return count item = value.get("item") if isinstance(item, dict): count = _count_image_generation_items([item]) if count: return count choices = value.get("choices") if isinstance(choices, list): return sum( _count_data_url_images( ((choice.get("message") if isinstance(choice, dict) else {}) or {}).get("content") if isinstance(choice, dict) else "" ) + _count_data_url_images( ((choice.get("delta") if isinstance(choice, dict) else {}) or {}).get("content") if isinstance(choice, dict) else "" ) for choice in choices ) response = value.get("response") if isinstance(response, dict): return _count_successful_images(response) return 0 def _attach_account_pool(payload: dict[str, object], identity: dict[str, object]) -> None: has_personal_pool = identity.get("role") == "admin" or bool(identity.get("account_pool_enabled")) payload["account_owner_id"] = str(identity.get("id") or "").strip() if not has_personal_pool: payload["account_pool_strategy"] = "admin_only" payload["account_allow_shared"] = False return payload["account_pool_strategy"] = str(payload.get("account_pool_strategy") or "own_first") payload["account_allow_shared"] = bool(payload.get("account_allow_shared", True)) 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 _response_text(value: object) -> str: if not isinstance(value, dict): return "" direct = value.get("output_text") if isinstance(direct, str) and direct.strip(): return direct.strip() output = value.get("output") if not isinstance(output, list): return "" parts: list[str] = [] for item in output: if not isinstance(item, dict): continue content = item.get("content") if not isinstance(content, list): continue parts.extend( str(part.get("text") or "") for part in content if isinstance(part, dict) and str(part.get("type") or "") in {"text", "output_text"} ) return "".join(parts).strip() def _split_grants(grants: list[dict[str, object]], amount: int | float) -> tuple[list[dict[str, object]], list[dict[str, object]]]: remaining = Decimal(str(_credit_amount(amount))) used: list[dict[str, object]] = [] refunded: list[dict[str, object]] = [] for grant in grants: if not isinstance(grant, dict): continue grant_amount = Decimal(str(_credit_amount(grant.get("amount")))) grant_id = str(grant.get("id") or "").strip() if not grant_id or grant_amount <= 0: continue take = min(grant_amount, remaining) if take > 0: used.append({"id": grant_id, "amount": _credit_amount(take)}) if grant_amount - take > 0: refunded.append({"id": grant_id, "amount": _credit_amount(grant_amount - take)}) remaining -= take return used, refunded class CreditReservation: def __init__(self, user_id: str = "", amount: int | float = 0): self.user_id = user_id self.amount = _credit_amount(amount) self.daily_amount: int | float = 0 self.paid_amount: int | float = 0 self.package_reservations: list[dict[str, object]] = [] self.grant_reservations: list[dict[str, object]] = [] self.settled = False @classmethod def reserve( cls, identity: dict[str, object], amount: int | float, *, model_id: str = DAILY_GIFT_ACCOUNT_POOL_MODEL_ID, ) -> "CreditReservation": if str(identity.get("role") or "").strip().lower() != "normal": return cls() reservation = cls(str(identity.get("id") or "").strip(), amount) if not reservation.user_id or reservation.amount <= 0: return reservation details = auth_service.reserve_credits( reservation.user_id, reservation.amount, model_id=model_id, ).get("_credit_reservation", {}) if not isinstance(details, dict): details = {} reservation.daily_amount = _credit_amount(details.get("daily")) reservation.paid_amount = _credit_amount(details.get("permanent_paid") if "permanent_paid" in details else details.get("paid")) reservation.package_reservations = details.get("packages") if isinstance(details.get("packages"), list) else [] reservation.grant_reservations = details.get("grants") if isinstance(details.get("grants"), list) else [] return reservation def settle(self, consume_amount: int | float) -> None: if self.settled: return if not self.user_id or self.amount <= 0: self.settled = True return consume_count = _credit_amount(min(float(self.amount), float(_credit_amount(consume_amount)))) daily_to_consume = _credit_amount(min(float(self.daily_amount), float(consume_count))) remaining = _credit_amount(float(consume_count) - float(daily_to_consume)) packages_to_consume, packages_to_refund = _split_grants(self.package_reservations, remaining) remaining = _credit_amount(float(remaining) - sum(float(_credit_amount(item.get("amount"))) for item in packages_to_consume)) grants_to_consume, grants_to_refund = _split_grants(self.grant_reservations, remaining) remaining = _credit_amount(float(remaining) - sum(float(_credit_amount(item.get("amount"))) for item in grants_to_consume)) paid_to_consume = _credit_amount(min(float(self.paid_amount), float(remaining))) daily_to_refund = _credit_amount(float(self.daily_amount) - float(daily_to_consume)) paid_to_refund = _credit_amount(float(self.paid_amount) - float(paid_to_consume)) if consume_count > 0: auth_service.consume_reserved_credits( self.user_id, consume_count, daily_amount=daily_to_consume, paid_amount=paid_to_consume, package_reservations=packages_to_consume, grant_reservations=grants_to_consume, ) refund_count = _credit_amount(float(self.amount) - float(consume_count)) if refund_count > 0: auth_service.refund_reserved_credits( self.user_id, refund_count, daily_amount=daily_to_refund, paid_amount=paid_to_refund, package_reservations=packages_to_refund, grant_reservations=grants_to_refund, ) self.settled = True class InternalImageModelDailyLimitReservation: def __init__(self, *, user_id: str = "", model_id: str = "", amount: int = 0, scope: str = ""): self.user_id = user_id self.model_id = model_id self.amount = max(0, int(amount or 0)) self.scope = scope self.settled = False @classmethod def reserve( cls, identity: dict[str, object], model_id: object, amount: int, ) -> "InternalImageModelDailyLimitReservation": if str(identity.get("role") or "").strip().lower() != "user": return cls() user_id = str(identity.get("id") or "").strip() normalized_model_id = str(model_id or "gpt-image-2").strip() or "gpt-image-2" if not user_id: return cls() result = auth_service.reserve_internal_image_model_daily_limit( user_id, model_id=normalized_model_id, amount=amount, ) reservation = result.get("_internal_image_model_daily_limit_reservation") if isinstance(result, dict) else {} reserved_amount = max(0, int(reservation.get("amount") or 0)) if isinstance(reservation, dict) else 0 reserved_scope = str(reservation.get("scope") or "").strip() if isinstance(reservation, dict) else "" return cls(user_id=user_id, model_id=normalized_model_id, amount=reserved_amount, scope=reserved_scope) def settle(self, success_count: int) -> None: if self.settled: return success_amount = min(self.amount, max(0, int(success_count or 0))) refund_amount = self.amount - success_amount if self.user_id and self.model_id and success_amount > 0: consume_kwargs: dict[str, object] = { "model_id": self.model_id, "amount": success_amount, } if self.scope: consume_kwargs["scope"] = self.scope auth_service.consume_internal_image_model_daily_limit( self.user_id, **consume_kwargs, ) if self.user_id and self.model_id and refund_amount > 0: refund_kwargs: dict[str, object] = { "model_id": self.model_id, "amount": refund_amount, } if self.scope: refund_kwargs["scope"] = self.scope auth_service.refund_internal_image_model_daily_limit( self.user_id, **refund_kwargs, ) self.settled = True class ImageBillingReservation: def __init__( self, *, mode: str = "", user_id: str = "", amount: int | float = 0, per_image_cost: int | float = 1, group_id: str = "", model_id: str = "", credit_reservation: CreditReservation | None = None, internal_limit_reservation: InternalImageModelDailyLimitReservation | None = None, pricing: dict[str, object] | None = None, ): self.mode = mode self.user_id = user_id self.amount = _credit_amount(amount) self.per_image_cost = _credit_amount(per_image_cost) self.group_id = group_id self.model_id = model_id self.credit_reservation = credit_reservation self.internal_limit_reservation = internal_limit_reservation self.pricing = dict(pricing or {}) self.actual = 0 self.refund = 0 self.log_detail: dict[str, object] = {} if self.pricing: self.log_detail.update({ "billing_base_unit_price": self.pricing.get("base_unit_price", 1), "billing_effective_unit_price": self.pricing.get("unit_price", 1), "billing_pay_percentage": self.pricing.get("pay_percentage", 100), "billing_rule_id": self.pricing.get("rule_id", ""), "billing_rule_name": self.pricing.get("rule_name", ""), "billing_estimated": self.pricing.get("estimated_total", self.amount), "billing_actual": 0, "billing_refund": 0, "billing_priced_at": self.pricing.get("priced_at") or self.pricing.get("evaluated_at", ""), }) self.settled = False @classmethod def reserve(cls, identity: dict[str, object], payload: dict[str, object], *, requested_amount: int | None = None) -> "ImageBillingReservation": amount = _to_positive_image_count(requested_amount if requested_amount is not None else payload.get("n"), 1) resolution = str(payload.get("resolution") or payload.get("image_resolution") or "").strip().lower() group_id = str(payload.get("third_party_image_group_id") or "").strip() use_third_party = resolution in {"1k", "2k", "4k"} or bool(group_id) route = None if not use_third_party: find_named_route = getattr(third_party_image_service, "find_named_route", None) route = find_named_route(payload.get("model"), advance=False) if callable(find_named_route) else None use_third_party = route is not None if not use_third_party: model_id = str(payload.get("model") or "gpt-image-2").strip() or "gpt-image-2" is_normal = str(identity.get("role") or "").strip().lower() == "normal" pricing = pool_pricing_service.quote(identity, quantity=amount) if is_normal else {} estimated_total = _credit_amount(pricing.get("estimated_total", amount)) return cls( mode="general", credit_reservation=CreditReservation.reserve( identity, estimated_total, model_id=DAILY_GIFT_ACCOUNT_POOL_MODEL_ID, ), internal_limit_reservation=InternalImageModelDailyLimitReservation.reserve(identity, model_id, amount), amount=estimated_total, per_image_cost=_credit_amount(pricing.get("unit_price", 1)), pricing=pricing, ) require_4k = resolution == "4k" if route is None and str(identity.get("role") or "").strip().lower() == "normal": find_wallet_route = getattr(third_party_image_service, "find_wallet_route_for_identity", None) if callable(find_wallet_route): route = find_wallet_route( identity, payload.get("model"), group_id=group_id, require_4k=require_4k, resolution=resolution, advance=False, ) if route is None: route = third_party_image_service.find_route( payload.get("model"), group_id=group_id, require_4k=require_4k, resolution=resolution or "1k", advance=False, ) if route is None: raise ValueError("No enabled image API model route") try: third_party_image_service.assert_access(identity, route) except PermissionError as exc: raise PermissionError(str(exc)) from exc payload["_third_party_image"] = True payload["_third_party_image_group_id"] = str(route.group.get("id") or "") payload["third_party_image_group_id"] = str(route.group.get("id") or "") payload["resolution"] = str(route.model.get("resolution") or resolution or "1k") per_image_cost = max(0, third_party_image_service.cost_for_route(route, payload.get("quality") or payload.get("image_quality"), 1)) total_cost = per_image_cost * amount group_id = str(route.group.get("id") or "") model_id = str(route.model.get("id") or "") billing_mode = str(route.group.get("billing_mode") or "") if billing_mode == "general_multiplier" and _third_party_wallet_remaining(identity, group_id, model_id) > 0: # A stale group setting must not strand an existing model-specific # dedicated balance. Treat it as "both" until that balance is used. billing_mode = "both" internal_limit_reservation = InternalImageModelDailyLimitReservation.reserve(identity, model_id, amount) if str(identity.get("role") or "").strip().lower() != "normal": return cls( mode=billing_mode, amount=0, per_image_cost=per_image_cost, group_id=group_id, model_id=model_id, internal_limit_reservation=internal_limit_reservation, ) user_id = str(identity.get("id") or "").strip() wallet_pricing = _third_party_activity_quote(identity, amount, group_id=group_id, base_unit_price=1) wallet_total_cost = _credit_amount(wallet_pricing.get("estimated_total", amount)) wallet_unit_cost = _credit_amount(wallet_pricing.get("unit_price", 1)) if billing_mode == "dedicated_wallet" or ( billing_mode == "both" and per_image_cost > 0 and _third_party_wallet_remaining(identity, group_id, model_id) >= wallet_total_cost ): if wallet_total_cost > 0: try: auth_service.reserve_third_party_image_wallet( user_id, group_id=group_id, model_id=model_id, amount=wallet_total_cost, ) except ValueError: if billing_mode != "both": raise else: return cls( mode="dedicated_wallet", user_id=user_id, amount=wallet_total_cost, per_image_cost=wallet_unit_cost, group_id=group_id, model_id=model_id, internal_limit_reservation=internal_limit_reservation, pricing=wallet_pricing, ) else: return cls( mode="dedicated_wallet", user_id=user_id, amount=0, per_image_cost=0, group_id=group_id, model_id=model_id, internal_limit_reservation=internal_limit_reservation, pricing=wallet_pricing, ) credit_pricing = _third_party_activity_quote(identity, amount, group_id=group_id, base_unit_price=per_image_cost) total_cost = _credit_amount(credit_pricing.get("estimated_total", total_cost)) effective_unit_cost = _credit_amount(credit_pricing.get("unit_price", per_image_cost)) return cls( mode=billing_mode if billing_mode == "both" else "general_multiplier", amount=total_cost, per_image_cost=effective_unit_cost, credit_reservation=CreditReservation.reserve( identity, total_cost, model_id=f"{group_id}:{model_id}", ), internal_limit_reservation=internal_limit_reservation, group_id=group_id, model_id=model_id, pricing=credit_pricing, ) def settle(self, success_count: int) -> None: if self.settled: return if self.internal_limit_reservation is not None: self.internal_limit_reservation.settle(success_count) success_cost = _credit_amount(min(float(self.amount), max(0, int(success_count or 0)) * float(self.per_image_cost))) if self.mode == "dedicated_wallet" and self.user_id and self.amount > 0: if success_cost > 0: auth_service.consume_third_party_image_wallet( self.user_id, group_id=self.group_id, model_id=self.model_id, amount=success_cost, ) refund = self.amount - success_cost if refund > 0: auth_service.refund_third_party_image_wallet( self.user_id, group_id=self.group_id, model_id=self.model_id, amount=refund, ) elif self.credit_reservation is not None: self.credit_reservation.settle(success_cost) self.actual = success_cost self.refund = _credit_amount(float(self.amount) - float(success_cost)) if self.pricing: self.log_detail["billing_actual"] = self.actual self.log_detail["billing_refund"] = self.refund self.settled = True async def run_metered_image_call( call: LoggedCall, handler, payload: dict[str, object], identity: dict[str, object], *, requested_amount: int | None = None, ): try: reservation = ImageBillingReservation.reserve(identity, payload, requested_amount=requested_amount) except PermissionError as exc: raise HTTPException(status_code=403, detail={"error": str(exc)}) from exc except ValueError as exc: raise HTTPException(status_code=402, detail={"error": str(exc)}) from exc call.billing_detail = reservation.log_detail stream_success_count = 0 def on_stream_item(item: object) -> None: nonlocal stream_success_count stream_success_count += _count_successful_images(item) def on_stream_complete() -> None: reservation.settle(stream_success_count) def on_stream_error(_exc: BaseException) -> None: reservation.settle(stream_success_count) try: result = await call.run( handler, payload, on_stream_item=on_stream_item, on_stream_complete=on_stream_complete, on_stream_error=on_stream_error, on_result=lambda value: reservation.settle(_count_successful_images(value)), ) except Exception: reservation.settle(0) raise if isinstance(result, StreamingResponse): return result reservation.settle(_count_successful_images(result)) return result def _external_prompt_optimization(prompt: str) -> tuple[str, str]: base_url = config.prompt_optimizer_base_url.rstrip("/") model = config.prompt_optimizer_model if not base_url: raise ValueError("第三方提示词优化 Base URL 不能为空") endpoint = base_url if base_url.endswith("/chat/completions") else f"{base_url}/chat/completions" headers = {"Content-Type": "application/json"} if config.prompt_optimizer_api_key: headers["Authorization"] = f"Bearer {config.prompt_optimizer_api_key}" response = curl_requests.post( endpoint, headers=headers, json={ "model": model, "stream": False, "messages": [ {"role": "system", "content": config.prompt_optimizer_system_prompt}, { "role": "user", "content": "只优化下面的原始提示词,保持原语言并仅返回改写后的提示词:\n" + prompt, }, ], }, timeout=60, ) if response.status_code >= 400: raise RuntimeError(f"第三方提示词优化接口返回 HTTP {response.status_code}: {response.text[:500]}") data = response.json() text = _chat_completion_text(data) if isinstance(data, dict) else "" if not text: raise RuntimeError("第三方提示词优化接口未返回有效内容") return text, model def _external_reverse_prompt(image: str) -> tuple[str, str]: base_url = config.reverse_prompt_base_url.rstrip("/") model = config.reverse_prompt_model if not base_url: raise ValueError("第三方反推提示词 Base URL 不能为空") endpoint = base_url if base_url.endswith("/chat/completions") else f"{base_url}/chat/completions" headers = {"Content-Type": "application/json"} if config.reverse_prompt_api_key: headers["Authorization"] = f"Bearer {config.reverse_prompt_api_key}" instruction = config.reverse_prompt_system_prompt response = curl_requests.post( endpoint, headers=headers, json={ "model": model, "stream": False, "messages": [ {"role": "system", "content": instruction}, { "role": "user", "content": [ {"type": "text", "text": instruction}, {"type": "image_url", "image_url": {"url": image}}, ], }, ], }, timeout=120, ) if response.status_code >= 400: raise RuntimeError(f"第三方反推提示词接口返回 HTTP {response.status_code}: {response.text[:500]}") data = response.json() text = _chat_completion_text(data) if isinstance(data, dict) else "" if not text: raise RuntimeError("第三方反推提示词接口未返回有效内容") return text, model def collect_models_result(identity: dict[str, object]) -> dict[str, Any]: """上游账号池模型 + 第三方接入模型,合并且按 id 去重后的完整目录。 `/v1/models`(对外 OpenAI 兼容)与 `/api/chat/models`(对话页、 管理页的「对话可用模型」)必须用同一份目录,否则两边口径会不一致—— 之前 `/api/chat/models` 只取 `openai_v1_models.list_models()`, 漏掉了在这里额外拼接的第三方模型,导致第三方模型在对话候选里根本不出现。 """ result = openai_v1_models.list_models() data = result.get("data") if isinstance(result, dict) else None if not isinstance(data, list): return result seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)} for item in third_party_image_service.public_models_for_identity(identity): model_id = str(item.get("id") or "").strip() if not model_id or model_id in seen: continue data.append(item) seen.add(model_id) return result def create_router() -> APIRouter: router = APIRouter() @router.get("/v1/models") async def list_models(authorization: str | None = Header(default=None)): identity = require_identity(authorization) try: return await run_in_threadpool(collect_models_result, identity) except Exception as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc @router.get("/api/chat/models") async def list_chat_models(authorization: str | None = Header(default=None)): """对话页模型下拉 / 管理页「对话可用模型」的数据源。 `/v1/models` 是给外部 API 客户端用的 OpenAI 兼容接口,必须保持全量 (含第三方生图模型),所以这里另开一个接口:全量候选 + 每个模型的 `chat_enabled` 标记(自动规则叠加人工覆盖)。 候选包含第三方接入模型——它们默认隐藏(生图模型),但可以被手动开启; 将来第三方接入文本 / 多模态模型时,只要不带生图能力就会默认出现在对话下拉里。 """ identity = require_identity(authorization) try: result = await run_in_threadpool(collect_models_result, identity) except Exception as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc items = result.get("data") if isinstance(result, dict) else None overrides = config.chat_models data = resolve_chat_models(items if isinstance(items, list) else [], overrides) return { "object": "list", "data": data, "include": overrides["include"], "exclude": overrides["exclude"], } @router.get("/api/image/models") async def list_image_models(authorization: str | None = Header(default=None)): """生图页模型下拉 / 管理页「生图可用模型」的数据源。 只列出能生图的模型(账号池上游内置生图模型 + 第三方接入生图模型), 每个带 `image_enabled` 标记。默认全部启用 —— 这里的开关只是展示层过滤, 不改动第三方生图配置(计费 / 路由 / 额度 / 对外 API 均不受影响)。 """ identity = require_identity(authorization) try: result = await run_in_threadpool(collect_models_result, identity) except Exception as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc items = result.get("data") if isinstance(result, dict) else None overrides = config.image_models data = resolve_image_models(items if isinstance(items, list) else [], overrides) return { "object": "list", "data": data, "include": overrides["include"], "exclude": overrides["exclude"], } @router.get("/api/system/models") async def list_system_models(authorization: str | None = Header(default=None)): """管理页「系统可用模型」的数据源。 与 `/v1/models` 同一份全量目录(上游账号池 + 第三方接入), 额外带上 `system_enabled` 标记:管理员可以把不关心的模型从这张列表里收起来。 注意这**只是展示层过滤**:`/v1/models` 本身仍保持全量, 上游账号池、第三方配置、计费与路由都不受影响。 """ identity = require_identity(authorization) try: result = await run_in_threadpool(collect_models_result, identity) except Exception as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc items = result.get("data") if isinstance(result, dict) else None overrides = config.system_models data = resolve_system_models(items if isinstance(items, list) else [], overrides) return { "object": "list", "data": data, "include": overrides["include"], "exclude": overrides["exclude"], } @router.post("/v1/images/generations") async def generate_images( body: ImageGenerationRequest, request: Request, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) payload = body.model_dump(mode="python") payload["base_url"] = resolve_image_base_url(request) _attach_account_pool(payload, identity) call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt) await filter_or_log(call, body.prompt) return await run_metered_image_call(call, openai_v1_image_generations.handle, payload, identity) @router.post("/v1/images/edits") async def edit_images( request: Request, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) payload, image_sources, mask_sources = await parse_image_edit_request(request) prompt = str(payload["prompt"]) model = str(payload["model"]) call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt) await filter_or_log(call, prompt) payload["images"] = await read_image_sources(image_sources) if mask_sources: payload["mask"] = await read_image_sources(mask_sources) payload["base_url"] = resolve_image_base_url(request) _attach_account_pool(payload, identity) return await run_metered_image_call(call, openai_v1_image_edit.handle, payload, identity) @router.post("/v1/chat/completions") async def create_chat_completion( body: ChatCompletionRequest, request: Request, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) payload = body.model_dump(mode="python") model = str(payload.get("model") or "auto") request_preview = request_text(payload.get("prompt"), payload.get("messages")) is_image_request = is_image_chat_request(payload) payload["base_url"] = resolve_image_base_url(request) if is_image_request: _attach_account_pool(payload, identity) call = LoggedCall(identity, "/v1/chat/completions", model, "图片生成" if is_image_request else "文本生成", request_text=request_preview) if is_image_request: # AI review protects image accounts. Plain chat is also used by # internal Skill controllers and image planners; reviewing their # system protocols creates false positives and never protects an # image-account call because no image account is used here. await filter_or_log(call, request_preview) return await run_metered_image_call( call, openai_v1_chat_complete.handle, payload, identity, requested_amount=parse_image_count(payload.get("n")), ) return await call.run(openai_v1_chat_complete.handle, payload, identity) @router.post("/api/prompt/optimize") async def optimize_prompt( body: PromptOptimizationRequest, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) prompt = body.prompt.strip() if not prompt: raise HTTPException(status_code=400, detail={"error": "提示词不能为空"}) if config.prompt_optimizer_provider == "external": try: optimized_prompt, model = await run_in_threadpool(_external_prompt_optimization, prompt) except (ValueError, RuntimeError) as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc return {"prompt": optimized_prompt, "model": model} payload = { "model": config.prompt_optimizer_model, "stream": False, "messages": [ {"role": "system", "content": config.prompt_optimizer_system_prompt}, { "role": "user", "content": ( f"{config.prompt_optimizer_system_prompt}\n\n" "Improve only the original prompt below. Preserve its language exactly and return only the rewritten prompt.\n" f"\n{prompt}\n" ), }, ], } call = LoggedCall(identity, "/api/prompt/optimize", config.prompt_optimizer_model, "提示词优化", request_text=prompt) result = await call.run(openai_v1_chat_complete.handle, payload, identity) if not isinstance(result, dict): return result optimized_prompt = _chat_completion_text(result) if not optimized_prompt: raise HTTPException(status_code=502, detail={"error": "模型未返回优化后的提示词"}) return {"prompt": optimized_prompt, "model": config.prompt_optimizer_model} @router.post("/api/prompt/reverse") async def reverse_prompt( body: ReversePromptRequest, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) image = body.image.strip() header, separator, encoded = image.partition(",") if not separator or not header.startswith("data:image/") or ";base64" not in header or not encoded.strip(): raise HTTPException(status_code=400, detail={"error": "图片数据无效"}) if config.reverse_prompt_provider == "external": try: prompt, model = await run_in_threadpool(_external_reverse_prompt, image) except (ValueError, RuntimeError) as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc return {"prompt": prompt, "model": model} instruction = config.reverse_prompt_system_prompt model = config.reverse_prompt_model payload = { "model": model, "stream": False, "instructions": instruction, "input": [ {"type": "input_text", "text": instruction}, {"type": "input_image", "image_url": image}, ], } call = LoggedCall(identity, "/api/prompt/reverse", model, "反推提示词", request_text="参考图片") result = await call.run(openai_v1_response.handle, payload) if not isinstance(result, dict): return result prompt = _response_text(result) if not prompt: raise HTTPException(status_code=502, detail={"error": "模型未返回反推提示词"}) return {"prompt": prompt, "model": model} @router.post("/api/image-annotations/identify") async def identify_annotation_region( body: ImageRegionRecognitionRequest, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) try: return await run_in_threadpool( identify_image_region, identity, body.image, body.bounds.model_dump(mode="python"), ) except ValueError as exc: raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc except RuntimeError as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc @router.post("/v1/responses") async def create_response( body: ResponseCreateRequest, request: Request, authorization: str | None = Header(default=None), ): identity = require_identity(authorization) payload = body.model_dump(mode="python") model = str(payload.get("model") or "auto") request_preview = request_text(payload.get("input"), payload.get("instructions")) is_image_request = has_response_image_generation_tool(payload) if is_image_request: payload["base_url"] = resolve_image_base_url(request) _attach_account_pool(payload, identity) call = LoggedCall(identity, "/v1/responses", model, "Responses 图片生成" if is_image_request else "Responses", request_text=request_preview) if is_image_request: await filter_or_log(call, request_preview) return await run_metered_image_call( call, openai_v1_response.handle, payload, identity, requested_amount=1, ) return await call.run(openai_v1_response.handle, payload) @router.post("/v1/messages") async def create_message( body: AnthropicMessageRequest, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None, alias="x-api-key"), anthropic_version: str | None = Header(default=None, alias="anthropic-version"), ): identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None)) payload = body.model_dump(mode="python") model = str(payload.get("model") or "auto") request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools")) call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview) return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic") @router.post("/v1/search") async def search(body: SearchRequest, authorization: str | None = Header(default=None)): identity = require_identity(authorization) call = LoggedCall(identity, "/v1/search", openai_search.MODEL, "搜索", request_text=body.prompt) return await call.run(openai_search.handle, body.model_dump(mode="python")) @router.get("/v1/editable-file-tasks") async def list_editable_file_tasks(ids: str = "", authorization: str | None = Header(default=None)): identity = require_identity(authorization) task_ids = [item.strip() for item in ids.split(",") if item.strip()] return await run_in_threadpool(editable_file_task_service.list_tasks, identity, task_ids) @router.get("/files/{file_path:path}") async def download_editable_file(file_path: str, download: bool = False): if file_path.startswith("skills/"): await run_in_threadpool(skill_service.cleanup_expired_runs) try: path = await run_in_threadpool(editable_file_task_service.public_file_path, file_path) except Exception as exc: raise HTTPException(status_code=404, detail={"error": "file not found"}) from exc media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" if path.suffix.lower() == ".svg": media_type = "image/svg+xml" if download: return FileResponse(path, media_type=media_type, filename=path.name, content_disposition_type="attachment") return FileResponse(path, media_type=media_type) @router.post("/v1/ppt/generations") async def create_ppt_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)): identity = require_identity(authorization) await filter_or_log(LoggedCall(identity, "/v1/ppt/generations", "gpt-5-5-thinking", "PPT生成任务", request_text=body.prompt), body.prompt) return await run_in_threadpool( editable_file_task_service.submit_ppt, identity, client_task_id=body.client_task_id or "", prompt=body.prompt, base64_images=body.base64_images, base_url=resolve_image_base_url(request), ) @router.post("/v1/psd/generations") async def create_psd_task(body: EditableFileTaskRequest, request: Request, authorization: str | None = Header(default=None)): identity = require_identity(authorization) await filter_or_log(LoggedCall(identity, "/v1/psd/generations", "gpt-5-5-thinking", "PSD生成任务", request_text=body.prompt), body.prompt) return await run_in_threadpool( editable_file_task_service.submit_psd, identity, client_task_id=body.client_task_id or "", prompt=body.prompt, base64_images=body.base64_images, base_url=resolve_image_base_url(request), ) return router