{"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"}}}}