Files

1 line
9.3 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "478201622/plugins", "files": {"astrbot_plugin_gemini_image_generation/tl/api/sensenova.py": {"8c3cecfe5267332ca9713fc70ab6c3d09677c9d4": {"t": 1785768260.8589282, "n": 8302, "h": "4d7976962410102b", "c": "\"\"\"SenseNova(商汤日日新)图像生成供应商实现。\n\n支持模型:\n- ``sensenova-u1-fast``:基于 SenseNova U1 的加速版本,专供信息图(Infographics)生成场景。\n\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Any\n\nimport aiohttp\nfrom astrbot.api import logger\n\nfrom ..api_types import APIError, ApiRequestConfig\nfrom ..tl_utils import save_base64_image\nfrom .base import ProviderRequest\n\n# 官方默认 API Base\n_DEFAULT_API_BASE: str = \"https://token.sensenova.cn\"\n\n# 默认模型\n_DEFAULT_MODEL: str = \"sensenova-u1-fast\"\n\n# 11 种官方支持的尺寸(width x height)\n_ALLOWED_SIZES: tuple[str, ...] = (\n \"1664x2496\", # 2:3\n \"2496x1664\", # 3:2\n \"1760x2368\", # 3:4\n \"2368x1760\", # 4:3\n \"1824x2272\", # 4:5\n \"2272x1824\", # 5:4\n \"2048x2048\", # 1:1\n \"2752x1536\", # 16:9\n \"1536x2752\", # 9:16\n \"3072x1376\", # 21:9\n \"1344x3136\", # 9:21\n)\n\n# aspect_ratio → size 映射(覆盖插件配置中所有 aspect_ratio 选项)\n_ASPECT_TO_SIZE: dict[str, str] = {\n \"1:1\": \"2048x2048\",\n \"2:3\": \"1664x2496\",\n \"3:2\": \"2496x1664\",\n \"3:4\": \"1760x2368\",\n \"4:3\": \"2368x1760\",\n \"4:5\": \"1824x2272\",\n \"5:4\": \"2272x1824\",\n \"16:9\": \"2752x1536\",\n \"9:16\": \"1536x2752\",\n \"21:9\": \"3072x1376\",\n \"9:21\": \"1344x3136\",\n}\n\n_DEFAULT_SIZE: str = \"2752x1536\"\n\n# prompt 最大 token 数\n_PROMPT_CHAR_SOFT_LIMIT: int = 4096\n\n\ndef _normalize_aspect_ratio(value: Any) -> str | None:\n \"\"\"将 'WxH' / 'W×H' / 'W:H' 归一化为 'W:H'。\"\"\"\n if value is None:\n return None\n text = str(value).strip().lower().replace(\"×\", \":\").replace(\"x\", \":\")\n if \":\" not in text:\n return None\n parts = text.split(\":\", 1)\n try:\n w = int(parts[0].strip())\n h = int(parts[1].strip())\n except (TypeError, ValueError):\n return None\n if w <= 0 or h <= 0:\n return None\n return f\"{w}:{h}\"\n\n\ndef _resolve_size(\n *,\n explicit_size: Any,\n aspect_ratio: str | None,\n default_size: str | None,\n) -> str:\n \"\"\"决定最终 size。\n\n 优先级:\n 1. ``explicit_size`` 合法且在白名单 → 直接使用\n 2. ``aspect_ratio`` 能映射到白名单 → 使用映射结果\n 3. ``default_size`` 合法且在白名单 → 使用\n 4. 兜底使用 ``_DEFAULT_SIZE``\n \"\"\"\n if explicit_size:\n text = str(explicit_size).strip().lower().replace(\"×\", \"x\")\n if text in _ALLOWED_SIZES:\n return text\n logger.warning(\n \"[sensenova] 显式 size=%s 不在官方支持列表,将根据 aspect_ratio 重选\",\n explicit_size,\n )\n\n ratio = _normalize_aspect_ratio(aspect_ratio)\n if ratio and ratio in _ASPECT_TO_SIZE:\n return _ASPECT_TO_SIZE[ratio]\n if ratio:\n logger.info(\n \"[sensenova] aspect_ratio=%s 不在官方支持列表,将回退到默认尺寸\", ratio\n )\n\n if default_size:\n text = str(default_size).strip().lower().replace(\"×\", \"x\")\n if text in _ALLOWED_SIZES:\n return text\n logger.warning(\n \"[sensenova] sensenova_settings.default_size=%s 非法,已忽略\", default_size\n )\n\n return _DEFAULT_SIZE\n\n\ndef _coerce_n(value: Any, default: int = 1) -> int:\n try:\n n = int(value)\n except (TypeError, ValueError):\n return default\n return max(1, min(n, 4))\n\n\ndef _ensure_v1_endpoint(api_base: str) -> str:\n base = (api_base or \"\").strip().rstrip(\"/\")\n if not base:\n base = _DEFAULT_API_BASE\n if base.endswith(\"/v1\"):\n return f\"{base}/images/generations\"\n return f\"{base}/v1/images/generations\"\n\n\nclass SenseNovaProvider:\n \"\"\"SenseNova ``/v1/images/generations`` 端点实现。\"\"\"\n\n name = \"sensenova\"\n\n async def build_request(\n self, *, client: Any, config: ApiRequestConfig\n ) -> ProviderRequest: # noqa: ANN401\n settings: dict[str, Any] = (\n getattr(config, \"provider_settings\", None)\n or getattr(client, \"sensenova_settings\", None)\n or {}\n )\n\n api_base = config.api_base or settings.get(\"api_base\") or _DEFAULT_API_BASE\n url = _ensure_v1_endpoint(str(api_base))\n\n if not config.api_key:\n raise APIError(\n \"SenseNova 缺少 API Key,请在 provider_overrides.sensenova.api_keys 中配置\",\n None,\n \"missing_api_key\",\n retryable=False,\n )\n\n # 参考图不支持,给出明确日志而非静默\n if config.reference_images:\n logger.info(\n \"[sensenova] U1 Fast 不支持图像输入,已忽略 %d 张参考图\",\n len(config.reference_images),\n )\n\n prompt = (config.prompt or \"\").strip()\n if not prompt:\n raise APIError(\n \"SenseNova 需要非空 prompt\", None, \"empty_prompt\", retryable=False\n )\n if len(prompt) > _PROMPT_CHAR_SOFT_LIMIT:\n logger.warning(\n \"[sensenova] prompt 长度 %d 超过软上限 %d,可能被服务端截断\",\n len(prompt),\n _PROMPT_CHAR_SOFT_LIMIT,\n )\n\n model = (\n config.model or settings.get(\"model\") or _DEFAULT_MODEL\n ).strip() or _DEFAULT_MODEL\n\n size = _resolve_size(\n explicit_size=settings.get(\"size\"),\n aspect_ratio=config.aspect_ratio,\n default_size=settings.get(\"default_size\"),\n )\n\n n = _coerce_n(settings.get(\"n\", 1))\n\n payload: dict[str, Any] = {\n \"model\": model,\n \"prompt\": prompt,\n \"size\": size,\n \"n\": n,\n }\n\n headers = {\n \"Authorization\": f\"Bearer {config.api_key}\",\n \"Content-Type\": \"application/json\",\n }\n\n logger.debug(\n \"[sensenova] build_request: url=%s model=%s size=%s n=%s prompt_len=%s\",\n url,\n model,\n size,\n n,\n len(prompt),\n )\n return ProviderRequest(url=url, headers=headers, payload=payload)\n\n async def parse_response(\n self,\n *,\n client: Any,\n response_data: dict[str, Any],\n session: aiohttp.ClientSession,\n api_base: str | None = None,\n http_status: int | None = None,\n request_config: ApiRequestConfig | None = None,\n ) -> tuple[list[str], list[str], str | None, str | None]: # noqa: ANN401\n image_urls: list[str] = []\n image_paths: list[str] = []\n\n data = response_data.get(\"data\")\n if not isinstance(data, list) or not data:\n error_obj = response_data.get(\"error\")\n if error_obj:\n error_msg = (\n error_obj.get(\"message\", \"未知错误\")\n if isinstance(error_obj, dict)\n else str(error_obj)\n )\n raise APIError(\n f\"SenseNova 图像生成失败: {error_msg}\",\n http_status,\n \"api_error\",\n error_obj.get(\"code\") if isinstance(error_obj, dict) else None,\n retryable=False,\n )\n raise APIError(\n \"SenseNova 响应缺少 data 字段\",\n http_status,\n \"invalid_response\",\n retryable=True,\n )\n\n for item in data:\n if not isinstance(item, dict):\n continue\n url = item.get(\"url\")\n if isinstance(url, str) and url:\n if client._request_has_proxy(request_config):\n _, image_path = await client._download_image(\n url,\n session,\n use_cache=False,\n proxy=client._request_http_proxy(request_config),\n )\n if image_path:\n image_urls.append(image_path)\n image_paths.append(image_path)\n continue\n image_urls.append(url)\n logger.debug(\"[sensenova] 图片 URL: %s...\", url[:80])\n continue\n b64 = item.get(\"b64_json\")\n if isinstance(b64, str) and b64:\n saved = await save_base64_image(b64, \"png\")\n if saved:\n image_urls.append(saved)\n image_paths.append(saved)\n\n if not image_urls:\n raise APIError(\n \"SenseNova 未返回图片数据\",\n http_status,\n \"no_image\",\n retryable=False,\n )\n\n logger.debug(\"[sensenova] 共 %d 张图片\", len(image_urls))\n return image_urls, image_paths, None, None\n"}}}}