From 6b057d460b9c2ff4ac94ae7b861fd7855babfe5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:04:35 +0800 Subject: [PATCH 1/6] Add media generation endpoints with local asset store and WebUI media page - /v1/images/generations, /v1/images/edits (JSON), /v1/videos submit/query/content backed by upstream /v2/images/* and /v2/videos/*; chat endpoints reject media models with a pointer to the media API - Media routing: cn-cli whitelist, catalog membership with static fallback, positive balance; replay only on 401/403/429 or unsent bodies, 5xx/timeout never replayed; 401 alone cools accounts; video tasks stay account-bound - media.sqlite3 + media/ store: header-sniffed file whitelist, atomic commits, TTL and capacity eviction, CSP-sandboxed public serving at /v1/media/* - media_public_base_url / media_max_total_mb / media_ttl_hours settings with WebUI media page (library, tasks, models, settings drawer), admin /admin/media API, logs protocol badges, preview simulation and seeds, tests and docs --- .env.example | 8 + README.md | 3 + README.zh-CN.md | 3 + app/admin_api.py | 118 +++++ app/gateway_management.py | 2 +- app/media_generation.py | 227 +++++++++ app/media_store.py | 302 ++++++++++++ app/request_context.py | 3 +- app/runtime_management.py | 8 +- app/settings.py | 10 +- app/site_routing.py | 5 + converter.py | 627 ++++++++++++++++++++++++- docs/clients.md | 11 + docs/clients.zh-CN.md | 11 + docs/webui.md | 9 + docs/webui.zh-CN.md | 9 + preview/__main__.py | 1 + preview/seed.py | 61 ++- preview/upstream.py | 70 ++- tests/test_media_generation.py | 631 +++++++++++++++++++++++++ tests/webui_fixture.py | 6 + web/e2e/backend.integration.ts | 45 ++ web/e2e/layout.spec.ts | 67 +++ web/e2e/media.spec.ts | 210 +++++++++ web/src/App.tsx | 3 + web/src/components.tsx | 2 + web/src/i18n.tsx | 168 +++++++ web/src/media.test.tsx | 197 ++++++++ web/src/pages/Logs.tsx | 12 + web/src/pages/Media.tsx | 823 +++++++++++++++++++++++++++++++++ web/src/ui.module.scss | 112 +++++ web/src/values.tsx | 6 + 32 files changed, 3756 insertions(+), 14 deletions(-) create mode 100644 app/media_generation.py create mode 100644 app/media_store.py create mode 100644 tests/test_media_generation.py create mode 100644 web/e2e/media.spec.ts create mode 100644 web/src/media.test.tsx create mode 100644 web/src/pages/Media.tsx diff --git a/.env.example b/.env.example index ece5bc3..a65e476 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,14 @@ CODEBUDDY2API_ADMIN_CSRF=true # CODEBUDDY2API_BACKUP_INTERVAL_HOURS=24 # CODEBUDDY2API_BACKUP_KEEP=7 +# Local media storage for generated images and videos. The base URL prefixes public asset +# links; when empty images return b64 only and saved videos expose no link. +# 生成图片与视频的本地存储。资源访问地址用于拼接公开资源链接;留空时图片只返回 +# b64,已保存的视频不回传链接。不设置时可在「媒体生成」页修改;显式设置会锁定。 +# CODEBUDDY2API_MEDIA_BASE_URL=https://media.example.com +# CODEBUDDY2API_MEDIA_MAX_TOTAL_MB=1024 +# CODEBUDDY2API_MEDIA_TTL_HOURS=72 + # Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it. # 不设置时工具元数据可在 WebUI 修改;显式布尔值会锁定。 # CODEBUDDY2API_KEEP_TOOL_METADATA=true diff --git a/README.md b/README.md index 77d5f7b..78371a7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ The template binds to localhost only. Configure HTTPS and restrict network acces | Anthropic Messages | `http://127.0.0.1:8787` | | MCP (web_search + web_fetch) | `http://127.0.0.1:8787/v1/mcp` | | REST web search / fetch | `POST http://127.0.0.1:8787/v1/search` / `POST http://127.0.0.1:8787/v1/fetch` | +| Image & video generation | `POST http://127.0.0.1:8787/v1/images/generations` / `POST /v1/images/edits` / `POST /v1/videos` | + +- Media generation is billed per upstream credit and only routes to verified account products; see [Media generation](docs/clients.md#media-generation). - **API key:** the same key used to sign in to the WebUI. - **Model:** a public ID from the WebUI or `GET /v1/models`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 016840a..5de3528 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,6 +47,9 @@ Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebu | Anthropic Messages | `http://127.0.0.1:8787` | | MCP(web_search + web_fetch 工具) | `http://127.0.0.1:8787/v1/mcp` | | REST 联网搜索 / 抓取 | `POST http://127.0.0.1:8787/v1/search` / `POST http://127.0.0.1:8787/v1/fetch` | +| 图像 / 视频生成 | `POST http://127.0.0.1:8787/v1/images/generations` / `POST /v1/images/edits` / `POST /v1/videos` | + +- 媒体生成按上游积分计费,只路由到已实测验证的账号产品;见[媒体生成](docs/clients.zh-CN.md#媒体生成)。 - **API Key**:与 WebUI 登录使用同一个密钥。 - **模型**:使用 WebUI 中的对外 ID,或查询 `GET /v1/models`。 diff --git a/app/admin_api.py b/app/admin_api.py index ee187fd..8eadfe6 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -18,6 +18,8 @@ from . import auth_oauth from . import perf_metrics +from . import media_store as media_store_module +from . import perf_metrics from .admin_auth import AdminAuth, AdminMiddleware, COOKIE_NAME, SESSION_TTL, error_response, same_origin from .control_store import ConflictError, validate_model from .credential_io import CredentialFileError, MAX_CREDENTIAL_BYTES, _valid_name, read_import_file @@ -743,6 +745,122 @@ async def logs_clear(request): return error_response(503, "日志清理未完成,请检查审计存储状态") return JSONResponse(result) + def media_store_or_none(): + store = config.get("media_store") + if store is None: + raise ValueError("媒体存储不可用,请检查数据目录权限或重启网关") + return store + + def media_page_params(request, *, status=False): + params = request.query_params + try: + limit = int(params.get("limit", "50")) + except ValueError: + raise ValueError("limit 必须为整数") from None + if not 1 <= limit <= 200: + raise ValueError("limit 必须为 1 至 200") + cursor = params.get("cursor", "") + if len(cursor) > 2048: + raise ValueError("cursor 过长") + filter_value = params.get("status" if status else "kind", "") + if status and filter_value not in ("", "active", "completed", "failed"): + raise ValueError("status 必须是 active、completed 或 failed") + if not status and filter_value not in ("", "image", "video"): + raise ValueError("kind 必须是 image 或 video") + return filter_value or None, cursor or None, limit + + @route("GET", "/admin/media") + async def media_summary(request): + def build(): + store = config.get("media_store") + resolved = {item["key"]: item for item in resolve_settings(config)} + base = str(config.get("media_public_base_url") or "") + return JSONResponse({ + "configured": bool(base), "base_url": base, + "locked": bool(resolved.get("media_public_base_url", {}).get("locked")), + "usage": store.usage() if store else {"total_bytes": 0, + "images": {"count": 0, "bytes": 0}, + "videos": {"count": 0, "bytes": 0}}, + "limit_bytes": int(config.get("media_max_total_mb") or 1024) * 1048576, + "ttl_hours": config.get("media_ttl_hours", 72), + "tasks": store.task_counts() if store else {"active": 0, "failed": 0}, + "models": gateway.admin_media_models(), + "available": store is not None, + }) + return await run_in_threadpool(build) + + @route("GET", "/admin/media/assets") + async def media_assets(request): + kind, cursor, limit = media_page_params(request) + + def build(): + page = media_store_or_none().list_assets(kind, cursor, limit) + base = str(config.get("media_public_base_url") or "") + items = [] + for item in page["items"]: + entry = dict(item) + if base: + entry["public_url"] = f"{base}/v1/media/{'videos' if item['kind'] == 'video' else 'images'}/{item['id']}" + items.append(entry) + return JSONResponse({**page, "items": items}) + return await run_in_threadpool(build) + + @route("GET", "/admin/media/assets/{asset_id}") + async def media_asset_meta(request): + def build(): + meta = media_store_or_none().get_asset(request.path_params["asset_id"]) + if meta is None: + return error_response(404, "媒体资源不存在或已清理") + base = str(config.get("media_public_base_url") or "") + if base: + meta = {**meta, + "public_url": f"{base}/v1/media/{'videos' if meta['kind'] == 'video' else 'images'}/{meta['id']}"} + return JSONResponse(meta) + return await run_in_threadpool(build) + + @route("GET", "/admin/media/assets/{asset_id}/content") + async def media_asset_content(request): + def build(): + store = media_store_or_none() + meta = store.get_asset(request.path_params["asset_id"]) + file = store.asset_file(meta) if meta else None + if file is None: + return error_response(404, "媒体资源不存在或已清理") + headers = media_store_module.security_headers(meta) + if request.query_params.get("download") == "1": + headers["Content-Disposition"] = f'attachment; filename="{file.name}"' + return FileResponse(file, media_type=meta["mime"], headers=headers) + return await run_in_threadpool(build) + + @route("DELETE", "/admin/media/assets/{asset_id}") + async def media_asset_delete(request): + def remove(): + asset_id = request.path_params["asset_id"] + if not media_store_or_none().delete_asset(asset_id): + return error_response(404, "媒体资源不存在或已清理") + event("media.asset_deleted", {"asset": asset_id}) + return JSONResponse({"deleted": asset_id}) + return await run_in_threadpool(remove) + + @route("GET", "/admin/media/tasks") + async def media_tasks(request): + status, cursor, limit = media_page_params(request, status=True) + + def build(): + return JSONResponse(media_store_or_none().list_tasks(status, cursor, limit)) + return await run_in_threadpool(build) + + @route("POST", "/admin/media/tasks/{task_id}/refresh") + async def media_task_refresh(request): + task_id = request.path_params["task_id"] + try: + task = await gateway._media_video_refresh(task_id, gateway._request_id()) + except HTTPException as exc: + detail = exc.detail.get("error", {}).get("message") if isinstance(exc.detail, dict) else None + return error_response(exc.status_code, detail or "视频任务不存在或无法刷新") + event("media.task_refreshed", {"task": task_id}) + return JSONResponse(gateway._media_video_response(task)) + @route("GET", "/admin/storage") async def storage_get(request): # A degraded store still reports its own health; a 503 here would hide diff --git a/app/gateway_management.py b/app/gateway_management.py index c185463..a88370b 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -434,7 +434,7 @@ def admin_apply_settings(self, values): def install_pages(app, directory): """Fallback only applies to pages; management/client APIs never serve HTML.""" root = Path(directory).resolve() - pages = {"", "models", "credentials", "logs", "storage", "settings", "updates", "login"} + pages = {"", "models", "credentials", "logs", "media", "storage", "settings", "updates", "login"} # Release detail pages carry the tag, whose dots would otherwise read as a file suffix. release_page = re.compile(r"updates/v\d+\.\d+\.\d+").fullmatch assets = StaticFiles(directory=root / "assets", check_dir=False) diff --git a/app/media_generation.py b/app/media_generation.py new file mode 100644 index 0000000..e0ceac9 --- /dev/null +++ b/app/media_generation.py @@ -0,0 +1,227 @@ +"""Media generation contracts: model lists, request shaping and status mapping. + +Pure data shaping shared by the gateway endpoints, the media store and the WebUI +administration API; nothing here touches FastAPI, the network or configuration. +""" +from __future__ import annotations + +from urllib.parse import urlsplit + +# Profiles verified against the paid media endpoints; extend only after live checks. +MEDIA_PROFILES = frozenset({"cn-cli"}) + +# Static families, listed model by model; account catalogs extend the validation set. +IMAGE_TEXT_MODELS = ( + "hunyuan-image-v3.0", "hunyuan-image-v3.0-art", "hunyuan-image-alpha", "hy-image-3.5", + "gemini-3.0-pro-image", "gemini-3.1-flash-image", "gemini-2.5-flash-image") +IMAGE_EDIT_MODELS = ( + "hunyuan-image-v2.0-general-edit", "hunyuan-image-alpha-edit", "hunyuan-image-v3.0-art", + "hy-image-3.5", "gemini-3.0-pro-image", "gemini-3.1-flash-image", "gemini-2.5-flash-image") +VIDEO_MODELS = ("minimax-video-h3", "hunyuan-video-art", "kling-video-v3") + +# Fixed defaults: never derived from catalog order, which is unstable across accounts. +DEFAULT_IMAGE_MODEL = "hunyuan-image-v3.0" +DEFAULT_IMAGE_EDIT_MODEL = "hunyuan-image-v2.0-general-edit" +DEFAULT_VIDEO_MODEL = "minimax-video-h3" + +GEMINI_IMAGE_MODELS = frozenset(("gemini-3.0-pro-image", "gemini-3.1-flash-image", "gemini-2.5-flash-image")) + +MAX_IMAGE_ITEMS = 4 # Public n bound; the upstream contract allows 1..4. +VIDEO_RESOLUTIONS = ("768P", "2K") +MAX_VIDEO_SECONDS = 60 + +# Upstream media task states mapped onto the public video status vocabulary. +VIDEO_STATUS_MAP = {"processing": "in_progress", "completed": "completed", "failed": "failed"} +PUBLIC_VIDEO_STATUSES = ("queued", "in_progress", "completed", "failed") + +_KIND_SOURCES = ( + ("image", IMAGE_TEXT_MODELS), ("edit", IMAGE_EDIT_MODELS), ("video", VIDEO_MODELS)) +_STATIC_MEDIA_MODELS = frozenset(model for _, models in _KIND_SOURCES for model in models) + + +def model_kinds(model) -> tuple: + """Return the media kinds a statically known model serves; empty for chat models.""" + if not isinstance(model, str): + return () + kinds = [] + for kind, models in _KIND_SOURCES: + if model in models: + kinds.append(kind) + return tuple(kinds) + + +def catalog_kinds(item) -> tuple: + """Classify an account-catalog model entry by its tags; only media tags count.""" + tags = item.get("tags") if isinstance(item, dict) else None + if not isinstance(tags, list): + return () + names = " ".join(str(tag).lower() for tag in tags if isinstance(tag, (str, int))) + kinds = [] + if "image" in names: + kinds.extend(("image", "edit")) + if "video" in names: + kinds.append("video") + return tuple(kinds) + + +def is_media_model(model, catalog_items=()) -> bool: + """Accept statically known media models plus catalog entries carrying media tags.""" + if model in _STATIC_MEDIA_MODELS: + return True + return any(model == item.get("id") and catalog_kinds(item) + for item in catalog_items if isinstance(item, dict)) + + +def normalize_media_base_url(value): + """Settings validator: empty, or an http(s) origin with an optional path prefix.""" + value = value.strip() + if not value: + return "" + try: + parts = urlsplit(value) + parts.port # Raises on an invalid port. + except ValueError: + raise ValueError("media_public_base_url: 地址无效") from None + if (parts.scheme not in ("http", "https") or not parts.hostname + or parts.username is not None or parts.password is not None + or parts.query or parts.fragment): + raise ValueError("media_public_base_url: 必须以 http:// 或 https:// 开头且不含查询参数") + return value.rstrip("/") + + +def _clean_text(value, field, limit=4096) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} 必须是非空字符串") + if len(value) > limit or any(ord(char) < 32 for char in value): + raise ValueError(f"{field} 长度或字符无效") + return value.strip() + + +def image_request_body(model: str, prompt: str, *, size=None, n=1, quality=None, + images=None, input_fidelity=None) -> dict: + """Shape one /v2/images request mirroring the CLI ImageService payload.""" + body = {"model": model, "prompt": _clean_text(prompt, "prompt")} + if size is not None: + body["size"] = _clean_text(size, "size", 64) + if model in GEMINI_IMAGE_MODELS: + # The gemini family returns base64 only when asked, like the CLI asks. + body["response_format"] = "b64_json" + if n and n > 1: + body["n"] = n + if quality is not None: + body["quality"] = _clean_text(quality, "quality", 64) + elif n: + body["n"] = n + if images is not None: + if not isinstance(images, (list, tuple)) or not images: + raise ValueError("images 必须是非空数组") + body["image"] = [_clean_text(item, "image_url", 262144) for item in images] + if input_fidelity is not None: + body["input_fidelity"] = _clean_text(input_fidelity, "input_fidelity", 64) + return body + + +def video_request_body(model: str, *, prompt=None, seconds=None, resolution=None, + aspect_ratio=None, enable_audio=None, image_url=None) -> dict: + """Shape one /v2/videos/generations request mirroring the CLI VideoService payload.""" + body = {"model": model, "watermark": True} + if prompt is not None: + body["prompt"] = _clean_text(prompt, "prompt") + if seconds is not None: + if isinstance(seconds, bool) or not isinstance(seconds, (int, float)) or float(seconds).is_integer() is False: + raise ValueError("seconds 必须是整数") + seconds = int(seconds) + if not 1 <= seconds <= MAX_VIDEO_SECONDS: + raise ValueError(f"seconds 必须在 1 到 {MAX_VIDEO_SECONDS} 之间") + body["seconds"] = seconds + extra = {} + if resolution is not None: + if resolution not in VIDEO_RESOLUTIONS: + raise ValueError(f"resolution 必须是 {' 或 '.join(VIDEO_RESOLUTIONS)}") + extra["resolution"] = resolution + if aspect_ratio is not None: + extra["aspect_ratio"] = _clean_text(aspect_ratio, "aspect_ratio", 32) + if enable_audio is not None: + extra["enable_audio"] = bool(enable_audio) + if extra: + body["extra_parameters"] = extra + if image_url is not None: + body["image_url"] = _clean_text(image_url, "image_url", 262144) + if "prompt" not in body and "image_url" not in body: + raise ValueError("prompt 与 image_url 至少提供一个") + return body + + +def _data_field(payload: dict): + data = payload.get("data") if isinstance(payload, dict) else None + return data if isinstance(data, dict) else {} + + +def parse_image_reply(payload) -> tuple[list, float | None]: + """Return (items, credit) from an image generation or edit reply. + + Accepts the item list and usage nested under ``data`` or at the top level; every + item keeps whatever of url / b64_json / revised_prompt the upstream supplied. + """ + if not isinstance(payload, dict): + raise ValueError("图像响应必须是 JSON 对象") + data = _data_field(payload) + items = data.get("data") if isinstance(data.get("data"), list) else payload.get("data") + if not isinstance(items, list) or not items: + raise ValueError("图像响应缺少 data 列表") + clean = [] + for item in items: + if not isinstance(item, dict): + continue + url = item.get("url") + b64 = item.get("b64_json") + if (isinstance(url, str) and url) or (isinstance(b64, str) and b64): + clean.append({key: value for key, value in item.items() + if key in ("url", "b64_json", "revised_prompt")}) + if not clean: + raise ValueError("图像响应缺少可用的图片条目") + for source in (data.get("usage"), payload.get("usage")): + if isinstance(source, dict) and isinstance(source.get("credit"), (int, float)): + return clean, float(source["credit"]) + return clean, None + + +def parse_video_submit(payload) -> str: + """Return the upstream task id from a video generation reply.""" + if not isinstance(payload, dict): + raise ValueError("视频响应必须是 JSON 对象") + task_id = _data_field(payload).get("id") or payload.get("id") + if not isinstance(task_id, str) or not task_id.strip(): + raise ValueError("视频响应缺少任务 ID") + return task_id.strip() + + +def map_video_status(status) -> str: + """Map an upstream task state; unknown non-terminal states stay in progress.""" + if not isinstance(status, str): + return "in_progress" + return VIDEO_STATUS_MAP.get(status.strip().lower(), "in_progress") + + +def parse_video_task(payload) -> tuple[str, list, float | None, str]: + """Return (public status, resource urls, credit, failure text) from a video task reply.""" + if not isinstance(payload, dict): + raise ValueError("视频任务响应必须是 JSON 对象") + data = _data_field(payload) + status = map_video_status(data.get("status") or payload.get("status")) + rows = data.get("data") if isinstance(data.get("data"), list) else payload.get("data") + urls = [item.get("url") for item in rows or [] + if isinstance(item, dict) and isinstance(item.get("url"), str) and item["url"]] + message = "" + for source in (data, payload): + for key in ("error", "message", "msg", "fail_reason"): + value = source.get(key) if isinstance(source, dict) else None + if isinstance(value, str) and value.strip(): + message = value.strip()[:500] + break + if message: + break + for source in (data.get("usage"), payload.get("usage")): + if isinstance(source, dict) and isinstance(source.get("credit"), (int, float)): + return status, urls, float(source["credit"]), message + return status, urls, None, message diff --git a/app/media_store.py b/app/media_store.py new file mode 100644 index 0000000..2003226 --- /dev/null +++ b/app/media_store.py @@ -0,0 +1,302 @@ +"""Local media assets and video task metadata in one sqlite-backed store. + +Files live under ``media/images|videos//.`` and are committed +atomically; metadata stays out of ControlStore snapshots and backups. Writing +first prunes expired objects and then evicts the oldest until the total fits. +""" +from __future__ import annotations + +import hashlib +import json +import os +import secrets +import sqlite3 +import threading +import time +from pathlib import Path + +DB_NAME = "media.sqlite3" +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 200 + +# Fixed magic numbers, sniffed before anything is committed to disk. +_MAGIC = ( + (b"\x89PNG\r\n\x1a\n", "png", "image/png"), + (b"\xff\xd8\xff", "jpg", "image/jpeg"), + (b"GIF87a", "gif", "image/gif"), + (b"GIF89a", "gif", "image/gif"), +) +_RIFF_WEBP = (b"RIFF", 0, b"WEBP", 8) +_FTYP = (b"ftyp", 4) + +KIND_MIMES = {"image": ("image/png", "image/jpeg", "image/webp", "image/gif"), + "video": ("video/mp4", "video/webm")} +KIND_PREFIX = {"image": "img_", "video": "vid_"} + + +def sniff(data: bytes): + """Identify an allowed media type from its header; None means rejection.""" + for magic, ext, mime in _MAGIC: + if data.startswith(magic): + return ext, mime + if data[:4] == _RIFF_WEBP[0] and data[8:12] == _RIFF_WEBP[2]: + return "webp", "image/webp" + if data[_FTYP[1]:_FTYP[1] + 4] == _FTYP[0]: + return "mp4", "video/mp4" + if data.startswith(b"\x1a\x45\xdf\xa3"): + return "webm", "video/webm" + return None + + +def security_headers(meta: dict) -> dict: + """Serving headers shared by the public and admin read routes.""" + return {"ETag": f'"{meta["sha256"]}"', "Cache-Control": "public, max-age=31536000, immutable", + "Content-Security-Policy": "sandbox; default-src 'none'", + "X-Content-Type-Options": "nosniff"} + + +def _row_to_asset(row) -> dict: + keys = ("id", "kind", "mime", "size", "sha256", "model", "created", "expires") + return dict(zip(keys, row)) + + +def _row_to_task(row) -> dict: + keys = ("task_id", "account_key", "profile", "model", "created", "updated", + "status", "media_id", "error", "credit") + return dict(zip(keys, row)) + + +class MediaStore: + """Own media/// files plus the assets and tasks tables.""" + + def __init__(self, root: Path): + self.root = Path(root) + self.directory = self.root / "media" + self.path = self.root / DB_NAME + self.directory.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + self._db = sqlite3.connect(self.path, check_same_thread=False) + self._db.execute("PRAGMA journal_mode=WAL") + self._db.execute("PRAGMA synchronous=NORMAL") + self._db.executescript(""" + CREATE TABLE IF NOT EXISTS assets( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, + sha256 TEXT NOT NULL, model TEXT, created REAL NOT NULL, expires REAL NOT NULL); + CREATE INDEX IF NOT EXISTS assets_created ON assets(created); + CREATE TABLE IF NOT EXISTS tasks( + task_id TEXT PRIMARY KEY, account_key TEXT NOT NULL, profile TEXT NOT NULL, + model TEXT, created REAL NOT NULL, updated REAL NOT NULL, status TEXT NOT NULL, + media_id TEXT, error TEXT, credit REAL); + CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created); + """) + self._db.commit() + + def close(self): + with self._lock: + try: + self._db.close() + except sqlite3.Error: + pass + + # -- assets ------------------------------------------------------------------------- + + def _asset_path(self, asset_id: str, ext: str) -> Path: + return self.directory / ("videos" if asset_id.startswith("vid_") else "images") \ + / asset_id[4:6] / f"{asset_id}.{ext}" + + def _new_id(self, kind: str) -> str: + return KIND_PREFIX[kind] + secrets.token_hex(16) + + def _total_bytes(self) -> int: + return self._db.execute("SELECT COALESCE(SUM(size),0) FROM assets").fetchone()[0] + + def save_asset(self, kind: str, data: bytes, *, model=None, ttl_hours: float, + max_total_bytes: int) -> dict: + """Sniff, prune and evict, then commit one asset atomically.""" + if kind not in KIND_MIMES or not isinstance(data, bytes) or not data: + raise ValueError("媒体类型或内容无效") + identified = sniff(data) + if identified is None or identified[1] not in KIND_MIMES[kind]: + raise ValueError("文件头不在允许的媒体类型白名单内") + ext, mime = identified + with self._lock: + now = time.time() + self.cleanup(ttl_hours=ttl_hours, max_total_bytes=max(0, max_total_bytes - len(data))) + for _ in range(4): # A collision keeps the existing object untouched. + asset_id = self._new_id(kind) + target = self._asset_path(asset_id, ext) + if not target.exists(): + break + else: + raise RuntimeError("无法分配新的媒体 ID") + target.parent.mkdir(parents=True, exist_ok=True) + pending = target.with_name(".incoming-" + target.name) + pending.write_bytes(data) + try: + os.replace(pending, target) + except OSError: + pending.unlink(missing_ok=True) + raise + try: + self._db.execute( + "INSERT INTO assets VALUES(?,?,?,?,?,?,?,?)", + (asset_id, kind, mime, len(data), hashlib.sha256(data).hexdigest(), + model, now, now + ttl_hours * 3600)) + self._db.commit() + except sqlite3.Error: + target.unlink(missing_ok=True) + raise + row = self._db.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() + return _row_to_asset(row) + + def get_asset(self, asset_id: str): + with self._lock: + row = self._db.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() + return _row_to_asset(row) if row else None + + def asset_file(self, meta: dict) -> Path | None: + """Return the committed file for a metadata row, or None when it is gone.""" + ext = (meta.get("mime") or "").split("/")[-1] + ext = {"jpeg": "jpg"}.get(ext, ext) + path = self._asset_path(meta["id"], ext) + return path if path.is_file() else None + + def delete_asset(self, asset_id: str) -> bool: + """Remove one file and row; bound tasks keep their status with media_id cleared.""" + with self._lock: + row = self._db.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() + if row is None: + return False + meta = _row_to_asset(row) + self._db.execute("UPDATE tasks SET media_id=NULL WHERE media_id=?", (asset_id,)) + self._db.execute("DELETE FROM assets WHERE id=?", (asset_id,)) + self._db.commit() + file = self.asset_file(meta) + if file is not None: + file.unlink(missing_ok=True) + return True + + def list_assets(self, kind=None, cursor=None, limit=DEFAULT_PAGE_SIZE) -> dict: + limit = max(1, min(int(limit), MAX_PAGE_SIZE)) + clauses, params = [], [] + if kind in KIND_MIMES: + clauses.append("kind=?") + params.append(kind) + if cursor: + try: + stamp, row_id = json.loads(cursor) + if not isinstance(stamp, (int, float)) or not isinstance(row_id, str): + raise ValueError() + except (ValueError, TypeError): + raise ValueError("invalid cursor") from None + clauses.append("(created limit + rows = rows[:limit] + return {"items": [_row_to_asset(row) for row in rows], "has_more": more, + "next_cursor": json.dumps([rows[-1][6], rows[-1][0]]) if more and rows else None} + + def usage(self) -> dict: + with self._lock: + rows = self._db.execute( + "SELECT kind, COUNT(*), COALESCE(SUM(size),0) FROM assets GROUP BY kind").fetchall() + by_kind = {name: {"count": 0, "bytes": 0} for name in KIND_MIMES} + total = 0 + for kind, count, size in rows: + by_kind[kind] = {"count": count, "bytes": size} + total += size + return {"total_bytes": total, "images": by_kind["image"], "videos": by_kind["video"]} + + def cleanup(self, *, ttl_hours: float, max_total_bytes: int) -> dict: + """Drop expired assets and task rows, then evict the oldest to fit the quota.""" + removed = {"expired": 0, "evicted": 0} + now = time.time() + with self._lock: + expired = self._db.execute( + "SELECT * FROM assets WHERE expires<=?", (now,)).fetchall() + for row in expired: + self.delete_asset(_row_to_asset(row)["id"]) + removed["expired"] = len(expired) + self._db.execute("DELETE FROM tasks WHERE created<=?", (now - ttl_hours * 3600,)) + self._db.commit() + while self._total_bytes() > max_total_bytes: + row = self._db.execute( + "SELECT * FROM assets ORDER BY created ASC, id ASC LIMIT 1").fetchone() + if row is None: + break + self.delete_asset(_row_to_asset(row)["id"]) + removed["evicted"] += 1 + self._db.commit() + return removed + + # -- video tasks -------------------------------------------------------------------- + + def create_task(self, task_id: str, account_key: str, profile: str, model) -> None: + with self._lock: + now = time.time() + self._db.execute("INSERT OR IGNORE INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?)", + (task_id, account_key, profile, model, now, now, "queued", None, None, None)) + self._db.commit() + + def get_task(self, task_id: str): + with self._lock: + row = self._db.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone() + return _row_to_task(row) if row else None + + def update_task(self, task_id: str, *, status=None, media_id=None, error=None, credit=None) -> None: + with self._lock: + current = self.get_task(task_id) + if current is None: + return + self._db.execute( + "UPDATE tasks SET updated=?, status=?, media_id=?, error=?, credit=? WHERE task_id=?", + (time.time(), + status if status is not None else current["status"], + media_id if media_id is not None else current["media_id"], + error if error is not None else current["error"], + credit if credit is not None else current["credit"], + task_id)) + self._db.commit() + + def list_tasks(self, status=None, cursor=None, limit=DEFAULT_PAGE_SIZE) -> dict: + limit = max(1, min(int(limit), MAX_PAGE_SIZE)) + clauses, params = [], [] + if status: + if status == "active": + clauses.append("status IN ('queued','in_progress')") + elif status in ("completed", "failed"): + clauses.append("status=?") + params.append(status) + else: + raise ValueError("invalid task status") + if cursor: + try: + stamp, row_id = json.loads(cursor) + if not isinstance(stamp, (int, float)) or not isinstance(row_id, str): + raise ValueError() + except (ValueError, TypeError): + raise ValueError("invalid cursor") from None + clauses.append("(created limit + rows = rows[:limit] + return {"items": [_row_to_task(row) for row in rows], "has_more": more, + "next_cursor": json.dumps([rows[-1][4], rows[-1][0]]) if more and rows else None} + + def task_counts(self) -> dict: + with self._lock: + rows = self._db.execute( + "SELECT status, COUNT(*) FROM tasks GROUP BY status").fetchall() + counts = dict(rows) + return {"active": counts.get("queued", 0) + counts.get("in_progress", 0), + "failed": counts.get("failed", 0)} diff --git a/app/request_context.py b/app/request_context.py index af7046d..f67e9c9 100644 --- a/app/request_context.py +++ b/app/request_context.py @@ -11,7 +11,8 @@ # Tracked inference entrypoints: /v1/messages/count_tokens stays excluded because it # performs token estimation only and must not claim an upstream session. PATHS = {"/v1/chat/completions": "chat", "/v1/responses": "responses", "/v1/messages": "messages", - "/v1/search": "search", "/v1/fetch": "fetch", "/v1/mcp": "mcp"} + "/v1/search": "search", "/v1/fetch": "fetch", "/v1/mcp": "mcp", + "/v1/images/generations": "images", "/v1/images/edits": "images", "/v1/videos": "videos"} _SCOPE_KEY = "codebuddy.request_context" _current = ContextVar("request_context", default=None) diff --git a/app/runtime_management.py b/app/runtime_management.py index a053238..b4460f4 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -12,6 +12,7 @@ from .control_store import ControlStore from .gateway_management import Management, install_pages from .inference_auth import InferenceAuthMiddleware +from .media_store import MediaStore from .model_policy import PolicyScopeMiddleware from .observability import AuditMiddleware from .settings import SCHEMA, apply_persisted_settings @@ -104,6 +105,11 @@ def initialize(gateway, args, argv=None, *, parser=None, dotenv_keys=()): except (OSError, ValueError, RuntimeError, sqlite3.Error) as error: config["audit_store"] = UnavailableAudit(type(error).__name__) print("[audit] 日志库不可用,推理服务继续;请检查目录权限或使用备份恢复日志库。", file=sys.stderr) + try: + config["media_store"] = MediaStore(root) + except (OSError, ValueError, sqlite3.Error) as error: + config["media_store"] = None + print(f"[media] 媒体存储不可用({type(error).__name__}),图像/视频接口将返回 503。", file=sys.stderr) config["management"] = Management(gateway) config["backups"] = Backups(config, data_dir=root, version=gateway.APP_VERSION) config["updater"] = Updater(config, root=Path(gateway.__file__).resolve().parent, @@ -127,7 +133,7 @@ def install(gateway): def close(config): - for key in ("audit_store", "control_store"): + for key in ("audit_store", "control_store", "media_store"): store = config.get(key) if store is not None: store.close() diff --git a/app/settings.py b/app/settings.py index 7b0c4dd..19de667 100644 --- a/app/settings.py +++ b/app/settings.py @@ -6,6 +6,7 @@ import re from urllib.parse import urlsplit +from .media_generation import normalize_media_base_url def normalize_allowed_origins(value): """Normalize a comma/space separated origin list; bare hosts default to HTTPS.""" @@ -161,7 +162,14 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "backup_interval_hours": _item(24, "integer", "自动备份间隔(小时)", env="CODEBUDDY2API_BACKUP_INTERVAL_HOURS", minimum=1, maximum=720), "backup_keep": _item(7, "integer", "自动备份保留份数", env="CODEBUDDY2API_BACKUP_KEEP", minimum=1, maximum=100), - # Interface preference: WebUI and `--lang` write it, nothing maps it from the environment. + # Local media storage for generated images and videos (app/media_store.py). + "media_public_base_url": _item("", "string", "资源访问地址", + env="CODEBUDDY2API_MEDIA_BASE_URL", allow_empty=True, max_length=200, + validator=normalize_media_base_url), + "media_max_total_mb": _item(1024, "integer", "媒体容量上限(MB)", + env="CODEBUDDY2API_MEDIA_MAX_TOTAL_MB", minimum=64, maximum=1048576), + "media_ttl_hours": _item(72, "integer", "媒体保留时长(小时)", + env="CODEBUDDY2API_MEDIA_TTL_HOURS", minimum=1, maximum=8760), "language": _item("auto", "string", "界面语言", choices=["auto", "zh", "en"], env_scope="ui", lockable=False), # One-time migration notice; written by `config migrate` and the WebUI dismiss button. diff --git a/app/site_routing.py b/app/site_routing.py index 88f6eba..e5ca3fe 100644 --- a/app/site_routing.py +++ b/app/site_routing.py @@ -149,5 +149,10 @@ def search_url_for_headers(headers: dict) -> str: def fetch_url_for_headers(headers: dict) -> str: return PROFILE_ENDPOINTS[profile_for_headers(headers)] + "/agenttool/v1/webfetch" +def media_url_for_headers(headers: dict, path: str) -> str: + """Map one media endpoint path onto the credential's fixed product endpoint.""" + return PROFILE_ENDPOINTS[profile_for_headers(headers)] + path + + def refresh_url_for_auth(auth: dict) -> str: return PROFILE_ENDPOINTS[profile_for_auth(auth)] + _REFRESH_PATH diff --git a/converter.py b/converter.py index 3588ab2..04cd9e2 100644 --- a/converter.py +++ b/converter.py @@ -2,6 +2,7 @@ """Expose CodeBuddy and WorkBuddy through compatible Chat, Responses and Messages APIs.""" from __future__ import annotations +import base64 import argparse import asyncio @@ -30,7 +31,7 @@ import httpx from fastapi import FastAPI, Header, HTTPException, Request from fastapi.exception_handlers import http_exception_handler as _default_http_exception_handler -from fastapi.responses import JSONResponse, Response, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response, StreamingResponse from starlette.concurrency import run_in_threadpool import uvicorn as uvicorn # Keep the existing embedding/test hook. @@ -95,8 +96,8 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app.site_routing import (DOMESTIC, INTERNATIONAL, PROFILE_ENDPOINTS, site_for_auth, site_for_headers, profile_for_auth, profile_for_headers, profile_region, profile_product, profile_site, chat_url_for_headers, search_url_for_headers, fetch_url_for_headers, - refresh_url_for_auth) -from app import dsml, mcp_server, server_tools, web_fetch, web_search + refresh_url_for_auth, media_url_for_headers) +from app import dsml, mcp_server, server_tools, web_fetch, web_search, media_generation, media_store from app.inbound_limits import anthropic_path from app.client_profiles import (CLI_VERSION, CLI_USER_AGENT, credential_headers, catalog_cache_key, account_key, canonical_uid, enterprise_id_of, identity_email, @@ -2598,6 +2599,15 @@ def _housekeep_once(pool: CredentialPool, ledger, *, pending_only=False): control_store.prune_daily_chats() except Exception as error: _log(f"[housekeeper] 打卡记录清理失败: {_network_error_text(error)}") + media = CONFIG.get("media_store") + if media is not None: + try: + removed = media.cleanup(ttl_hours=CONFIG.get("media_ttl_hours", 72), + max_total_bytes=int(CONFIG.get("media_max_total_mb") or 1024) << 20) + if removed.get("expired") or removed.get("evicted"): + _log(f"[housekeeper] 媒体清理:过期 {removed['expired']} 项,淘汰 {removed['evicted']} 项") + except Exception as error: + _log(f"[housekeeper] 媒体清理失败: {_network_error_text(error)}") ids = pool.begin_sync(all_entries=not pending_only) failed = set() try: @@ -3818,6 +3828,7 @@ def guard_model(name: str, *, region=None, resolved=False) -> None: if not isinstance(name, str) or not name.strip(): raise HTTPException(status_code=400, detail={"error": { "message": "model must be a non-empty string", "type": "invalid_request_error", "param": "model"}}) + _media_chat_guard(name) if not resolved: name = model_policy.resolve(CONFIG, name) model_policy.check_resolved(CONFIG, name) @@ -4931,6 +4942,451 @@ async def _web_fetch(url: str, prompt: str = "", max_length: int | None = None, return await _agenttool_call(_WEB_FETCH, body, rid, borrowed=borrowed) +# --------------------------------------------------------------------------- +# Media generation (images and videos) +# --------------------------------------------------------------------------- + +MEDIA_IMAGE_TIMEOUT = 180 +MEDIA_VIDEO_SUBMIT_TIMEOUT = 60 +MEDIA_VIDEO_QUERY_TIMEOUT = 30 +MEDIA_DOWNLOAD_TIMEOUT = 120 +_MEDIA_ID = re.compile(r"(img|vid)_[0-9a-f]{32}\Z") +_MEDIA_PUBLIC_KINDS = {"image": "images", "video": "videos"} + + +class MediaDownloadError(RuntimeError): + """An upstream resource download failed or exceeded the local media quota.""" + + +@dataclass(frozen=True) +class _MediaAction: + """One upstream media endpoint: where it lives and how long it may take.""" + name: str + path: str + timeout: float + + +MEDIA_IMAGE_GENERATE = _MediaAction("image-generate", "/v2/images/generations", MEDIA_IMAGE_TIMEOUT) +MEDIA_IMAGE_EDIT = _MediaAction("image-edit", "/v2/images/edits", MEDIA_IMAGE_TIMEOUT) +MEDIA_VIDEO_SUBMIT = _MediaAction("video-submit", "/v2/videos/generations", MEDIA_VIDEO_SUBMIT_TIMEOUT) +MEDIA_VIDEO_QUERY = _MediaAction("video-query", "/v2/videos/tasks", MEDIA_VIDEO_QUERY_TIMEOUT) + + +def _media_store(): + """Return the media store or refuse media endpoints with 503.""" + store = CONFIG.get("media_store") + if store is None: + raise HTTPException(status_code=503, detail={"error": { + "message": "媒体存储不可用,请检查数据目录权限或重启网关", "type": "service_unavailable"}}) + return store + + +def _media_base_url() -> str: + return str(CONFIG.get("media_public_base_url") or "").strip().rstrip("/") + + +def _media_ttl_hours() -> float: + try: + return float(CONFIG.get("media_ttl_hours") or 72) + except (TypeError, ValueError): + return 72.0 + + +def _media_max_bytes() -> int: + try: + return max(64, int(CONFIG.get("media_max_total_mb") or 1024)) * 1024 * 1024 + except (TypeError, ValueError): + return 1024 * 1024 * 1024 + + +def _media_public_url(meta: dict) -> str: + return f"{_media_base_url()}/v1/media/{_MEDIA_PUBLIC_KINDS[meta['kind']]}/{meta['id']}" + + +def _media_dynamic_kinds(model) -> list: + """Collect media kinds declared for this model across published account catalogs.""" + kinds: list = [] + for account in (CONFIG.get("account_catalogs") or {}).values(): + if not isinstance(account, dict): + continue + for item in account.get("models") or []: + if isinstance(item, dict) and item.get("id") == model: + kinds.extend(kind for kind in media_generation.catalog_kinds(item) if kind not in kinds) + return kinds + + +def _media_all_kinds(model) -> list: + kinds = list(media_generation.model_kinds(model)) + kinds.extend(kind for kind in _media_dynamic_kinds(model) if kind not in kinds) + return kinds + + +_MEDIA_ENDPOINT_HINT = "媒体生成请使用 /v1/images/generations、/v1/images/edits 或 /v1/videos 接口" + + +def _media_chat_guard(model) -> None: + """Keep media models out of chat routing with one shared check.""" + if model and _media_all_kinds(model): + raise HTTPException(status_code=400, detail={"error": { + "message": f"模型 {model} 是媒体生成模型,不走聊天接口;{_MEDIA_ENDPOINT_HINT}", + "type": "invalid_request_error", "code": "media_model_not_chat"}}) + + +def _media_check_model(model, kind: str) -> None: + """Accept only models in the static ∪ dynamic validation set for this endpoint kind.""" + if not isinstance(model, str) or kind not in _media_all_kinds(model): + raise HTTPException(status_code=400, detail={"error": { + "message": f"模型 {model} 不在媒体生成校验集内;{_MEDIA_ENDPOINT_HINT}", + "type": "invalid_request_error", "code": "model_not_found", "param": "model"}}) + + +def _media_account_serves(account, model) -> bool: + """Account catalog membership with a static-list fallback for unsynced accounts.""" + items = _account_scope(account, "models") if isinstance(account, dict) else None + if items is None: + return bool(media_generation.model_kinds(model)) + return any(isinstance(item, dict) and item.get("id") == model for item in items) + + +def _media_cred_for(model, tried=()): + """Lease one account allowed to serve paid media; mirror pool health and capacity.""" + pool = CONFIG.get("cred_pool") + if pool is None: + cm = CONFIG.get("cred") + if cm is None or cm in {_cred_manager(item) for item in tried}: + raise HTTPException(status_code=503, detail={"error": { + "message": "未找到可用的媒体生成凭证,请先在桌面端登录 CodeBuddy", "type": "auth_error"}}) + with cm._lock: + headers = cm.get_headers() + if profile_for_headers(headers) not in media_generation.MEDIA_PROFILES: + raise HTTPException(status_code=503, detail={"error": { + "message": "当前凭证产品不支持媒体生成", "type": "auth_error"}}) + return cm, headers + policy = model_policy.view(CONFIG) + accounts = CONFIG.get("account_catalogs") or {} + excluded = {_cred_manager(item) for item in tried} + with pool._lock: + candidates = [] + for entry in pool._entries: + profile = CredentialPool._entry_profile(entry) + if (entry["cm"] in excluded or not profile + or profile not in media_generation.MEDIA_PROFILES + or not pool._healthy(entry, policy) + or not _media_account_serves(accounts.get(entry.get("account_key")), model) + or not pool._has_credit(entry, profile)): + continue + candidates.append(entry) + candidates.sort(key=pool._expiry_rank) + if not candidates: + raise HTTPException(status_code=503, detail={"error": { + "message": "没有符合媒体路由条件的账号(需支持媒体的产品、目录包含该模型且有余额)", + "type": "service_unavailable", "code": "no_media_account"}}) + busy = False + for entry in candidates: + cm = entry["cm"] + try: + with cm._lock: + headers = cm.get_headers() + generation = cm._generation + except Exception as error: + pool.cooldown(cm, reason=str(error), generation=cm._generation) + continue + if profile_for_headers(headers) not in media_generation.MEDIA_PROFILES: + continue + lease = pool._capacity.acquire(pool._capacity_key(entry), + CONFIG.get("max_inflight_per_account", 0), cm, generation) + if lease is None: + busy = True + continue + resources = request_resources.get() + if resources is not None: + resources.add(lease) + return lease, headers + if busy: + raise HTTPException(status_code=503, headers={"Retry-After": "3"}, detail={"error": { + "message": "媒体生成账号在途名额已满,请稍后重试", "type": "service_unavailable", + "code": "credential_concurrency_limit"}}) + raise HTTPException(status_code=503, detail={"error": { + "message": "媒体生成账号暂时不可用(令牌刷新失败或产品不符),请稍后重试", + "type": "service_unavailable"}}) + + +def _media_bound_cred(account_identity: str): + """Lease the exact account a task was submitted with, ignoring health gates.""" + pool = CONFIG.get("cred_pool") + if pool is None: + cm = CONFIG.get("cred") + if cm is None: + raise HTTPException(status_code=503, detail={"error": { + "message": "任务绑定的凭证不存在,无法查询", "type": "auth_error"}}) + with cm._lock: + return cm, cm.get_headers() + with pool._lock: + entry = next((e for e in pool._entries if e.get("account_key") == account_identity), None) + if entry is None: + raise HTTPException(status_code=409, detail={"error": { + "message": "提交该任务的账号已移出凭证池,无法查询;请重新提交任务", + "type": "invalid_request_error", "code": "account_removed"}}) + with entry["cm"]._lock: + return entry["cm"], entry["cm"].get_headers() + + +def _media_replayable(error) -> bool: + """Replay only when the body was never accepted or upstream explicitly refused.""" + if isinstance(error, UpstreamHTTPError): + return error.status in (401, 403, 429) + return isinstance(error, REPLAYABLE_TRANSPORT) + + +async def _media_call(action: _MediaAction, body: dict, rid: str, *, model, parse, + cred=None, headers=None, allow_failover=True, lease_note=None): + """POST one media request and parse the reply; paid media never replays timeouts or 5xx.""" + tried = [] + recovered = None + owned = cred is None + while True: + if owned and cred is None: + cred, headers = await run_in_threadpool(_media_cred_for, model, tried) + profile = profile_for_headers(headers) + url = media_url_for_headers(headers, action.path) + observe_route(public_model=model, upstream_model=model, profile=profile, + credential=account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id"))) + t0 = time.time() + released = False + try: + async with _backend_stream(url, headers, body, timeout=action.timeout, + rid=rid, model_name=action.name) as response: + if response.status_code != 200: + raw = await read_bounded_error(response) + code, message, _ = _upstream_diagnostic(raw) + observe_attempt("upstream_error", status_code=response.status_code, + upstream_code=code, upstream_msg=message) + # Health isolation: only 401 reaches the cooldown/refresh path. + if response.status_code == 401: + _note_cred_status(cred, 401, raw=raw) + raise UpstreamHTTPError( + response.status_code, raw, + retry_after=parse_retry_after(response.headers.get("Retry-After"))) + raw = await response.aread() + try: + result = parse(json.loads(raw.decode("utf-8", "replace"))) + except ValueError as error: + observe_failure("bad_media_response") + raise HTTPException(status_code=502, detail={"error": { + "message": str(error), "type": "upstream_error"}}) from None + except (httpx.HTTPError, UpstreamResponseError) as error: + status, raw = _upstream_failure(error, action.name, t0, rid) + recovered = observe_failure_seq() + tried.append(cred) + limit = _failover_limit() + surface = _wrap_agenttool_error(status, raw, error) + if not allow_failover or limit <= 0 or len(tried) > limit or not _media_replayable(error): + raise surface from None + _log(f"[{rid}] ↻ 媒体换号重放 {len(tried)}/{limit} | {action.name} | 上游 HTTP {status}") + release_credential(cred) + released = True + try: + cred, headers = await run_in_threadpool(_media_cred_for, model, tried) + except HTTPException: + # No replacement account: report the original upstream refusal. + raise surface from None + owned = True + continue + finally: + if owned and not released: + release_credential(cred) + if tried: + observe_recovery(recovered) + if lease_note is not None: + lease_note["profile"] = profile + lease_note["credential"] = account_key(profile, headers.get("X-User-Id"), + headers.get("X-Enterprise-Id")) + _log(f"[{rid}] ✔ {action.name.upper()} | {profile} | {model} | {time.time() - t0:.1f}s") + return result + + +async def _media_download_bytes(url: str, rid: str) -> bytes: + """Fetch one upstream resource into memory, bounded by the configured media quota.""" + limit = _media_max_bytes() + data = bytearray() + async with httpx.AsyncClient(timeout=MEDIA_DOWNLOAD_TIMEOUT, follow_redirects=True) as client: + try: + async with client.stream("GET", url) as response: + if response.status_code != 200: + raise MediaDownloadError(f"资源下载失败(HTTP {response.status_code})") + async for chunk in response.aiter_bytes(64 * 1024): + data.extend(chunk) + if len(data) > limit: + raise MediaDownloadError("单个资源超过媒体容量上限") + except httpx.HTTPError as error: + raise MediaDownloadError(f"资源下载失败:{type(error).__name__}") from None + return bytes(data) + + +async def _media_save(kind: str, data: bytes, *, model, rid: str) -> dict: + """Persist downloaded bytes through the media store's sniffing and eviction.""" + if len(data) > _media_max_bytes(): + raise MediaDownloadError("单个资源超过媒体容量上限") + try: + return await run_in_threadpool(_media_store().save_asset, kind, data, model=model, + ttl_hours=_media_ttl_hours(), + max_total_bytes=_media_max_bytes()) + except ValueError: + raise + except Exception as error: + raise MediaDownloadError(f"媒体存储写入失败:{type(error).__name__}") from None + + +async def _media_video_refresh(task_id: str, rid: str) -> dict: + """Query one upstream video task on its bound account and persist the result.""" + store = _media_store() + task = store.get_task(task_id) + if task is None: + raise HTTPException(status_code=404, detail={"error": { + "message": "视频任务不存在或已过期", "type": "not_found_error", "code": "task_not_found"}}) + if task["status"] in ("completed", "failed"): + return task # Repeated queries keep returning the same resource or error. + cred, headers = await run_in_threadpool(_media_bound_cred, task["account_key"]) + status, urls, credit, message = await _media_call( + MEDIA_VIDEO_QUERY, {"task_id": task_id}, rid, model=task["model"], + parse=media_generation.parse_video_task, cred=cred, headers=headers, allow_failover=False) + if status == "failed": + store.update_task(task_id, status="failed", + error=message or "视频生成失败", credit=credit) + elif status == "completed": + media_id = task["media_id"] + if media_id is None and urls: + try: + meta = await _media_save("video", await _media_download_bytes(urls[0], rid), + model=task["model"], rid=rid) + media_id = meta["id"] + except MediaDownloadError as error: + _log(f"[{rid}] ✗ 视频资源下载失败 | {task_id} | {error}") + store.update_task(task_id, status="completed", + error=f"资源下载失败:{error}", credit=credit) + return store.get_task(task_id) + store.update_task(task_id, status="completed", media_id=media_id, credit=credit) + else: + store.update_task(task_id, status=status, credit=credit) + return store.get_task(task_id) + + +def _media_video_response(task: dict) -> dict: + """Render one task row in the public video shape.""" + body = {"id": task["task_id"], "object": "video", "status": task["status"], + "model": task["model"], "created_at": int(task["created"])} + if task["status"] == "completed": + meta = _media_store().get_asset(task["media_id"]) if task["media_id"] else None + if meta is not None and _media_base_url(): + body["video"] = {"url": _media_public_url(meta)} + elif meta is not None: + body["message"] = "视频已保存到网关本地;设置资源访问地址(media_public_base_url)后可获取链接" + elif task["error"]: + body["message"] = task["error"] + else: + body["message"] = "视频资源已删除或过期,不再重新下载" + elif task["status"] == "failed": + body["error"] = {"message": task["error"] or "视频生成失败"} + if task["credit"] is not None: + body["usage"] = {"credit": task["credit"]} + return body + + +def _media_bad_request(message: str) -> HTTPException: + return HTTPException(status_code=400, detail={"error": { + "message": message, "type": "invalid_request_error"}}) + + +async def _media_json(request: Request) -> dict: + try: + payload = await request.json() + except Exception as e: + raise _bad_json(e) from None + if not isinstance(payload, dict): + raise _media_bad_request("请求体必须是 JSON 对象") + return payload + + +def _media_optional_text(value, field: str, limit: int = 262144): + """Accept an optional public string field; None passes through untouched.""" + if value is None: + return None + if not isinstance(value, str) or len(value) > limit or any(ord(c) < 32 for c in value): + raise _media_bad_request(f"{field} 必须是不超过 {limit} 字符的字符串") + return value + + +async def _media_image_reply(items: list, model, rid: str, *, persist: bool) -> list: + """Convert upstream image items to the public shape, persisting only when serving URLs.""" + out = [] + for item in items: + revised = {"revised_prompt": item["revised_prompt"]} if item.get("revised_prompt") else {} + try: + if item.get("b64_json"): + if persist: + meta = await _media_save("image", base64.b64decode(item["b64_json"]), + model=model, rid=rid) + out.append({"url": _media_public_url(meta), **revised}) + else: + out.append({"b64_json": item["b64_json"], **revised}) + else: + data = await _media_download_bytes(item["url"], rid) + if persist: + meta = await _media_save("image", data, model=model, rid=rid) + out.append({"url": _media_public_url(meta), **revised}) + else: + out.append({"b64_json": base64.b64encode(data).decode("ascii"), **revised}) + except MediaDownloadError as error: + raise HTTPException(status_code=502, detail={"error": { + "message": str(error), "type": "upstream_error"}}) from None + except ValueError as error: + raise HTTPException(status_code=502, detail={"error": { + "message": f"图像内容无效,无法保存或编码:{error}", "type": "upstream_error"}}) from None + return out + + +def _media_model_accounts(model) -> int: + """Count accounts the media routing rules would currently select for this model.""" + pool = CONFIG.get("cred_pool") + if pool is None: + return 1 if CONFIG.get("cred") is not None else 0 + policy = model_policy.view(CONFIG) + accounts = CONFIG.get("account_catalogs") or {} + with pool._lock: + count = 0 + for entry in pool._entries: + profile = CredentialPool._entry_profile(entry) + if (profile and profile in media_generation.MEDIA_PROFILES + and model_policy.credential_enabled(CONFIG, entry, policy) + and _media_account_serves(accounts.get(entry.get("account_key")), model) + and pool._has_credit(entry, profile)): + count += 1 + return count + + +def admin_media_models() -> list: + """Model table for the media page: static families plus tagged catalog entries.""" + defaults = {"image": media_generation.DEFAULT_IMAGE_MODEL, + "edit": media_generation.DEFAULT_IMAGE_EDIT_MODEL, + "video": media_generation.DEFAULT_VIDEO_MODEL} + table: dict = {} + for kind, models in (("image", media_generation.IMAGE_TEXT_MODELS), + ("edit", media_generation.IMAGE_EDIT_MODELS), + ("video", media_generation.VIDEO_MODELS)): + for model in models: + table.setdefault(model, {"kinds": [], "catalog": False})["kinds"].append(kind) + for account in (CONFIG.get("account_catalogs") or {}).values(): + for item in ((account or {}).get("models") or []): + if isinstance(item, dict) and media_generation.catalog_kinds(item) \ + and isinstance(item.get("id"), str): + table.setdefault(item["id"], {"kinds": [], "catalog": False})["catalog"] = True + return [{"id": model, "kinds": entry["kinds"], + "sources": ["static"] + (["catalog"] if entry["catalog"] else []), + "default": [kind for kind, name in defaults.items() if name == model], + "accounts": _media_model_accounts(model)} + for model, entry in sorted(table.items())] + + def _mcp_tool(call, failure: str): """Adapt a gateway web tool for the MCP layer; map gateway errors to tool text instead of HTTP failures.""" async def run(*args): @@ -5360,6 +5816,171 @@ def _validated_count(value, default: int, low: int, high: int, field: str) -> tu return number, "" +@app.post("/v1/images/generations") +async def images_generations(request: Request, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + """Generate images through the upstream media endpoints.""" + _check_auth(authorization, x_api_key) + payload = await _media_json(request) + model = payload.get("model") or media_generation.DEFAULT_IMAGE_MODEL + _media_check_model(model, "image") + if not isinstance(payload.get("prompt"), str) or not payload["prompt"].strip(): + raise _media_bad_request("prompt 必须是非空字符串") + n, problem = _validated_count(payload.get("n"), 1, 1, media_generation.MAX_IMAGE_ITEMS, "n") + if problem: + raise _media_bad_request(problem) + if payload.get("response_format") not in (None, "url", "b64_json"): + raise _media_bad_request("response_format 必须是 url 或 b64_json") + size = _media_optional_text(payload.get("size"), "size", 64) + quality = _media_optional_text(payload.get("quality"), "quality", 64) + body = media_generation.image_request_body(model, payload["prompt"], size=size, n=n, quality=quality) + rid = _request_id() + _log(f"[{rid}] ▶ IMAGE | {model} | n={n} | chars={len(payload['prompt'])}") + items, credit = await _media_call(MEDIA_IMAGE_GENERATE, body, rid, model=model, + parse=media_generation.parse_image_reply) + persist = bool(_media_base_url()) and payload.get("response_format") != "b64_json" + data = await _media_image_reply(items, model, rid, persist=persist) + response = {"created": int(time.time()), "data": data} + if credit is not None: + response["usage"] = {"credit": credit} + return response + + +@app.post("/v1/images/edits") +async def images_edits(request: Request, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + """Edit images through the upstream media endpoints; JSON body only, no multipart.""" + _check_auth(authorization, x_api_key) + payload = await _media_json(request) + model = payload.get("model") or media_generation.DEFAULT_IMAGE_EDIT_MODEL + _media_check_model(model, "edit") + if not isinstance(payload.get("prompt"), str) or not payload["prompt"].strip(): + raise _media_bad_request("prompt 必须是非空字符串") + raw_images = payload.get("images") + if not isinstance(raw_images, list) or not raw_images: + raise _media_bad_request("images 必须是非空数组") + images = [] + for entry in raw_images: + url = entry.get("image_url") if isinstance(entry, dict) else None + if not isinstance(url, str) or not url.strip(): + raise _media_bad_request("每个 images 项必须包含非空 image_url") + images.append(url.strip()) + if len(images) > media_generation.MAX_IMAGE_ITEMS: + raise _media_bad_request(f"images 最多 {media_generation.MAX_IMAGE_ITEMS} 项") + if payload.get("response_format") not in (None, "url", "b64_json"): + raise _media_bad_request("response_format 必须是 url 或 b64_json") + size = _media_optional_text(payload.get("size"), "size", 64) + quality = _media_optional_text(payload.get("quality"), "quality", 64) + fidelity = _media_optional_text(payload.get("input_fidelity"), "input_fidelity", 64) + body = media_generation.image_request_body(model, payload["prompt"], size=size, n=1, + quality=quality, images=images, input_fidelity=fidelity) + rid = _request_id() + _log(f"[{rid}] ▶ IMAGE EDIT | {model} | images={len(images)} | chars={len(payload['prompt'])}") + items, credit = await _media_call(MEDIA_IMAGE_EDIT, body, rid, model=model, + parse=media_generation.parse_image_reply) + persist = bool(_media_base_url()) and payload.get("response_format") != "b64_json" + data = await _media_image_reply(items, model, rid, persist=persist) + response = {"created": int(time.time()), "data": data} + if credit is not None: + response["usage"] = {"credit": credit} + return response + + +@app.post("/v1/videos") +async def create_video(request: Request, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + """Submit one asynchronous video generation task.""" + _check_auth(authorization, x_api_key) + payload = await _media_json(request) + model = payload.get("model") or media_generation.DEFAULT_VIDEO_MODEL + _media_check_model(model, "video") + seconds = payload.get("seconds") + if isinstance(seconds, str): + try: + seconds = int(seconds.strip()) + except ValueError: + raise _media_bad_request("seconds 必须是整数或数字字符串") from None + elif isinstance(seconds, float) and seconds.is_integer(): + seconds = int(seconds) + try: + body = media_generation.video_request_body( + model, + prompt=_media_optional_text(payload.get("prompt"), "prompt"), + seconds=seconds, + resolution=_media_optional_text(payload.get("resolution"), "resolution", 16), + aspect_ratio=_media_optional_text(payload.get("aspect_ratio"), "aspect_ratio", 32), + enable_audio=payload.get("enable_audio"), + image_url=_media_optional_text(payload.get("image_url"), "image_url")) + except ValueError as error: + raise _media_bad_request(str(error)) from None + store = _media_store() + rid = _request_id() + _log(f"[{rid}] ▶ VIDEO SUBMIT | {model} | chars={len(payload.get('prompt') or '')}") + note = {} + task_id = await _media_call(MEDIA_VIDEO_SUBMIT, body, rid, model=model, + parse=media_generation.parse_video_submit, lease_note=note) + await run_in_threadpool(store.create_task, task_id, note.get("credential"), + note.get("profile"), model) + task = store.get_task(task_id) + return {"id": task_id, "object": "video", "status": "queued", "model": model, + "created_at": int((task or {}).get("created") or time.time())} + + +@app.get("/v1/videos/{task_id}") +async def get_video(task_id: str, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + """Report one video task, refreshing in-flight state from its bound account.""" + _check_auth(authorization, x_api_key) + task = await _media_video_refresh(task_id, _request_id()) + return _media_video_response(task) + + +@app.get("/v1/videos/{task_id}/content") +async def get_video_content(task_id: str, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): + """Redirect to the public media URL of a finished video.""" + _check_auth(authorization, x_api_key) + if not _media_base_url(): + raise HTTPException(status_code=409, detail={"error": { + "message": "未配置资源访问地址(media_public_base_url):视频已保存到网关本地,配置后即可获取链接", + "type": "invalid_request_error", "code": "media_base_url_missing"}}) + task = await _media_video_refresh(task_id, _request_id()) + meta = None + if task["status"] == "completed" and task["media_id"]: + meta = _media_store().get_asset(task["media_id"]) + if meta is None: + raise HTTPException(status_code=409, detail={"error": { + "message": "视频资源尚不可用(任务未完成或资源已清理)", + "type": "invalid_request_error", "code": "video_content_unavailable"}}) + return RedirectResponse(_media_public_url(meta), status_code=302) + + +@app.get("/v1/media/{kind}/{asset_id}", include_in_schema=False) +async def media_asset(kind: str, asset_id: str, request: Request): + """Serve one stored media asset publicly with immutable caching headers.""" + if kind not in ("images", "videos") or not _MEDIA_ID.fullmatch(asset_id) \ + or asset_id.startswith("img_") != (kind == "images"): + raise HTTPException(status_code=404, detail={"error": { + "message": "媒体资源不存在", "type": "not_found_error"}}) + store = CONFIG.get("media_store") + if store is None: + raise HTTPException(status_code=503, detail={"error": { + "message": "媒体存储不可用", "type": "service_unavailable"}}) + meta = store.get_asset(asset_id) + file = store.asset_file(meta) if meta is not None else None + if file is None: + raise HTTPException(status_code=404, detail={"error": { + "message": "媒体资源不存在或已清理", "type": "not_found_error"}}) + if request.headers.get("if-none-match") == f'"{meta["sha256"]}"': + return Response(status_code=304, headers=media_store.security_headers(meta)) + return FileResponse(file, media_type=meta["mime"], headers=media_store.security_headers(meta)) + + @app.post("/v1/mcp") async def mcp_endpoint(request: Request, authorization: Optional[str] = Header(default=None), diff --git a/docs/clients.md b/docs/clients.md index c476947..52d3a18 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -99,6 +99,17 @@ curl http://127.0.0.1:8787/v1/fetch -H "Authorization: Bearer YOUR_API_KEY" \ - Search results are not lost between turns. The model sees each result's title, link and snippet; emitted `web_search_result` blocks carry the snippet in a gateway-owned `encrypted_content` value (`cb2api:` prefix) and `web_search_call` items list `action.sources`. Clients that echo these items back (`server_tool_use`, `web_search_tool_result`, `web_fetch_tool_result`, `web_search_call`) get them replayed to the model as plain text context, not as tool calls. Other `encrypted_content` values, such as real Anthropic blobs, are ignored. Dated OpenAI types such as `web_search_preview_2025_03_11` and `web_search_2025_08_26` are accepted like their undated forms. - Gateway-executed tools do not create audit records of their own: each shows up as one `server_tool` attempt (`model` `web-search` or `web-fetch`, outcome, error code, profile, credential, tries, duration) inside the request that triggered it, which keeps its own model, profile and outcome. Its usage is the sum over every upstream round. +## Media generation + +Image and video endpoints backed by the upstream `/v2/images/*` and `/v2/videos/*` services; each request is billed with upstream credits. + +- `POST /v1/images/generations` — `{"prompt", "model?", "size?", "n?" (≤4), "quality?", "response_format?"}` → `{"created", "data": [{url | b64_json, revised_prompt?}], "usage"?}`. +- `POST /v1/images/edits` — OpenAI-style JSON: `{"prompt", "images": [{"image_url": "https://… | data:…"}], "model?", "size?", "input_fidelity?"}`; multipart is not supported. +- `POST /v1/videos` — `{"prompt?" , "image_url?", "seconds?" (int or numeric string), "resolution?" ("768P"|"2K"), "aspect_ratio?", "enable_audio?", "model?"}` → `{"id", "object": "video", "status": "queued", …}`; poll with `GET /v1/videos/{id}` and fetch finished videos via `GET /v1/videos/{id}/content`. +- Without the asset base URL (`media_public_base_url`, WebUI **Media**) images always return `b64_json` and saved videos expose no link; videos are always stored locally under `media/` and the link appears once the base URL is set. Assets are served publicly at `/v1/media/{images|videos}/{id}` with immutable caching. +- Defaults are fixed per family: `hunyuan-image-v3.0` (text to image), `hunyuan-image-v2.0-general-edit` (edit), `minimax-video-h3` (video). Models outside the static list ∪ account catalog are rejected with 400, and media models are refused on the chat endpoints with a pointer here. Upstream stays the final judge of model authorization. +- Accounts are picked per request from verified media-capable products only (currently `cn-cli`), require catalog membership and a positive balance, and a refused request (401/403/429) may be retried once on another account per the failover setting; 5xx and timeouts are never replayed to avoid double billing. Video tasks always stay bound to the account that submitted them. + ## Protocol behavior worth knowing - `developer` messages become `system`; the first system message is placed first before matching tool results, without mutating the original payload. diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 6a2cebd..639c8ce 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -99,6 +99,17 @@ curl http://127.0.0.1:8787/v1/fetch -H "Authorization: Bearer YOUR_API_KEY" \ - 网关执行的工具不会产生独立的审计记录:每次执行在触发它的请求中记为一条 `server_tool` 尝试(`model` 为 `web-search`/`web-fetch`,含结果、错误码、profile、凭证、尝试次数与耗时),该请求仍保留自己的模型、profile 与结果;其用量为所有上游轮次之和。 - 网关按请求选号,并按网关 failover 配置做一次换号重试;上游确定性拒绝原样返回。 +## 媒体生成 + +图像与视频端点,上游为 `/v2/images/*` 与 `/v2/videos/*`;每次请求按上游积分计费。 + +- `POST /v1/images/generations`:`{"prompt", "model?", "size?", "n?"(≤4), "quality?", "response_format?"}` → `{"created", "data": [{url | b64_json, revised_prompt?}], "usage"?}`。 +- `POST /v1/images/edits`:OpenAI 风格 JSON:`{"prompt", "images": [{"image_url": "https://… | data:…"}], "model?", "size?", "input_fidelity?"}`;不支持 multipart。 +- `POST /v1/videos`:`{"prompt?", "image_url?", "seconds?"(整数或数字字符串), "resolution?"("768P"|"2K"), "aspect_ratio?", "enable_audio?", "model?"}` → `{"id", "object": "video", "status": "queued", …}`;用 `GET /v1/videos/{id}` 轮询,`GET /v1/videos/{id}/content` 获取完成的视频。 +- 未配置资源访问地址(`media_public_base_url`,WebUI「媒体生成」页)时,图片一律返回 `b64_json`,已保存的视频不回传链接;视频始终落盘到 `media/`,配置后即可获得链接。资源经 `/v1/media/{images|videos}/{id}` 公开读取,带不可变缓存头。 +- 默认模型按族固定:文生图 `hunyuan-image-v3.0`、图生图 `hunyuan-image-v2.0-general-edit`、视频 `minimax-video-h3`。静态清单 ∪ 账号目录之外的模型返回 400;媒体模型打到聊天端点会被拒绝并指引到本节。授权类拒绝以上游为最终裁决。 +- 按请求选号,仅限已实测可用的媒体产品(当前为 `cn-cli`),要求目录包含该模型且余额为正;被明确拒绝(401/403/429)时可按 failover 配置换号重试一次,5xx 与超时一律不重放以避免重复扣费。视频任务始终绑定提交账号查询。 + ## 值得了解的协议行为 - 先将 `developer` 转为 `system` 并置顶首条系统消息,再关联工具结果;不改动调用方原始载荷。 diff --git a/docs/webui.md b/docs/webui.md index bc4bf3d..b13ae8e 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -49,6 +49,14 @@ Filter requests and inspect failed attempts. Closing details or switching log ty Clearing **all logs and statistics** is irreversible. Enter the confirmation text shown in the dialog and re-enter the current API key. This does not delete credentials or gateway settings. +## Media + +The **Media** page shows locally stored generation results: usage against the capacity limit, image and video totals, and active or failed video tasks. + +- **Library** lists assets newest first with model, size and remaining retention; a card opens the preview drawer with metadata, download and a two-step delete. **Video tasks** refresh in-flight tasks on their bound account and link finished videos back to the library. **Models** is a read-only table of media models and how many accounts could currently serve them. +- Images are only stored when an asset base URL is configured; without it they are returned inline as base64. Videos are always stored locally, and their link appears once the base URL is set. +- **Media settings** (`media_public_base_url`, `media_max_total_mb`, `media_ttl_hours`) set the public link prefix, capacity (64–1048576 MB) and retention (1–8760 hours). Shrinking either evicts the oldest assets on the next write or scheduled cleanup. Assets are also served publicly at `/v1/media/…` with immutable caching; see [Media generation](clients.md#media-generation). + ## Settings Edit unlocked options; hot changes apply immediately, while restart-marked settings require a manual restart. Change locked options in the startup configuration; see [configuration precedence](advanced.md#configuration-and-cli). The Dashboard banner lists runtime keys still taken from `.env`; stop the service, migrate them with `python3 converter.py config migrate`, or dismiss the banner once — after either action it never returns, even if `.env` gains more keys. @@ -106,6 +114,7 @@ Data defaults to `auth/`, or `/data/auth` inside Docker. Local installs can set | `*.info` | Official plaintext credentials; never migrated into SQLite | | `control.sqlite3` | Settings, private default key, model rules, sessions, cooldowns, credit/reward state, usage and catalogs | | `logs.sqlite3` | Request details and independent aggregate statistics | +| `media/` + `media.sqlite3` | Generated media files and their metadata; kept for the retention period and never included in configuration backups | Auditing defaults to 30-day detail retention and a 256 MiB logical detail budget, **not a hard limit on database or directory disk usage**. Detail cleanup and eviction preserve aggregates. SQLite failure diagnostics have a separate budget, defaulting to 8192 bytes. Existing text logs are retained, not backfilled as precise statistics. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index ed463b9..fc6f177 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -49,6 +49,14 @@ 「清空全部日志与统计」不可恢复:输入对话框中的确认文字,并重新输入当前 API key。此操作不删除凭证与网关配置。 +## 媒体生成 + +「媒体生成」页展示本地保存的生成结果:本地占用与容量上限、图片与视频数量、进行中与失败的视频任务。 + +- **资源库**按时间倒序列出资源,含模型、大小与剩余保留时长;卡片打开预览抽屉,可查看元数据、下载与两步删除。**视频任务**用绑定账号刷新进行中的任务,并把完成的视频链回资源库。**模型**为只读表格,展示媒体模型与当前可用的账号数。 +- 只有配置了资源访问地址,图片才会落盘;未配置时以 base64 内联返回。视频始终保存到本地,配置地址后即可获得链接。 +- **媒体设置**(`media_public_base_url`、`media_max_total_mb`、`media_ttl_hours`)分别设置公开链接前缀、容量(64–1048576 MB)与保留时长(1–8760 小时)。调小后会在下次写入或定时清理时淘汰最老的资源。资源同时经 `/v1/media/…` 公开读取,带不可变缓存头;见[媒体生成](clients.zh-CN.md#媒体生成)。 + ## 系统设置 编辑未锁定的选项;即时生效项立即应用,标记为重启的项需手动重启。被锁定的选项请在启动配置中修改;见[配置优先级](advanced.zh-CN.md#配置与-cli)。Dashboard 顶部横幅会列出仍由 `.env` 提供的运行参数;停止服务后用 `python3 converter.py config migrate` 迁移,或点一次“不再提醒”——两种方式之后都不再出现,即使 `.env` 又新增键。 @@ -106,6 +114,7 @@ | `*.info` | 官方明文凭证;绝不迁移进 SQLite | | `control.sqlite3` | 网关设置、私有默认 key、模型规则、会话、冷却、积分/领取、用量与目录缓存 | | `logs.sqlite3` | 请求明细与独立的聚合统计 | +| `media/` + `media.sqlite3` | 生成媒体文件与元数据;按保留时长清理,不进配置备份 | 审计默认保留 30 天明细与 256 MiB 逻辑明细预算,**不是数据库或目录磁盘占用的硬上限**。明细清理与淘汰都保留聚合。SQLite 失败诊断有独立预算,默认 8192 字节。已有文本日志保留原样,不回填为精确统计。 diff --git a/preview/__main__.py b/preview/__main__.py index ed852d6..6d6ee40 100644 --- a/preview/__main__.py +++ b/preview/__main__.py @@ -30,6 +30,7 @@ def prepare(directory: Path) -> Path: sys.exit(f"预览数据目录必须为空或由预览服务创建:{directory}") seed.write_credentials(directory) seed.write_history(directory / "logs.sqlite3") + seed.write_media(directory) (directory / MARKER).write_text("codebuddy2api preview data\n", encoding="utf-8") return directory diff --git a/preview/seed.py b/preview/seed.py index 038d166..acd8b55 100644 --- a/preview/seed.py +++ b/preview/seed.py @@ -3,8 +3,11 @@ from dataclasses import dataclass, field import json import random +import struct import time import uuid +import zlib +from pathlib import Path from app.audit_store import AuditStore from app.client_profiles import account_key @@ -29,17 +32,46 @@ class Account: revoked: bool = False daily_requests: int = 6 models: tuple = field(default=()) + media: tuple = () CN_MODELS = ("glm-5.1", "deepseek-v3.2", "kimi-k2", "claude-sonnet-4.6", "gpt-5.1") INTL_MODELS = ("claude-sonnet-4.6", "gpt-5.1", "gemini-2.5-pro") +# Media models advertised by personal domestic CLI accounts; image models never join chat. +CN_MEDIA_ALL = ("hunyuan-image-v3.0", "hunyuan-image-v3.0-art", "hy-image-3.5", + "gemini-3.0-pro-image", "hunyuan-image-v2.0-general-edit", "minimax-video-h3", + "hunyuan-video-art") +CN_MEDIA_MIN = ("hunyuan-image-v3.0", "minimax-video-h3") + +# The simulated COS bucket the media endpoints hand out signed URLs for. +COS_HOST = "cos.preview.invalid" +SEED_VIDEO_TASK_ID = "previewvideotask0000000000000001" + + + +def _png(size, rgb) -> bytes: + def chunk(tag, data): + body = tag + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) + header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) + raw = b"".join(b"\x00" + bytes(rgb) * size for _ in range(size)) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"") + + +PNG_BYTES = _png(48, (0x7C, 0x66, 0xDC)) +PNG_BYTES_ALT = _png(48, (0x2A, 0x9D, 0x8F)) +# A minimal ftyp-branded file: the store only sniffs headers, and the demo never plays it. +MP4_BYTES = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2" + b"\x00" * 4096 + ACCOUNTS = ( - Account("aurora", "cn-cli", "100001", "Aurora", daily_requests=14, models=CN_MODELS, packages=( + Account("aurora", "cn-cli", "100001", "Aurora", daily_requests=14, models=CN_MODELS, + media=CN_MEDIA_ALL, packages=( ("个人版订阅套餐", "plan-month", 1500, 2000, 18), ("签到奖励", "checkin", 24.5, 30, 3))), Account("breeze", "cn-work", "100002", "Breeze", daily_requests=8, models=CN_MODELS, packages=( ("新人权益包", "welcome", 600, 1000, 9), ("旅行奖励", "travel", 260, 300, 26))), - Account("cedar", "cn-cli", "100003", "Cedar", daily_requests=3, models=CN_MODELS, packages=( + Account("cedar", "cn-cli", "100003", "Cedar", daily_requests=3, models=CN_MODELS, + media=CN_MEDIA_MIN, packages=( ("活动奖励", "event", 42, 200, 2),)), Account("dune", "intl-cli", "200001", "Dune", daily_requests=6, models=INTL_MODELS, packages=( ("Pro plan", "pro", 420, 500, 21),)), @@ -119,3 +151,28 @@ def _record(account: Account, started: float, rng: random.Random) -> dict: "attempts": [{"outcome": "error" if failed else "success", "credential": identity, "status_code": status, "duration_ms": duration}], } + + +def write_media(directory, now: float | None = None): + """Seed local media assets and video tasks so the dashboard media page opens populated.""" + from app.media_store import MediaStore + + now = time.time() if now is None else now + store = MediaStore(Path(directory)) + try: + quota = {"ttl_hours": 72, "max_total_bytes": 1 << 30} + store.save_asset("image", PNG_BYTES, model="hunyuan-image-v3.0", **quota) + # A second image a moment older keeps the newest-first listing meaningful. + first = store.save_asset("image", PNG_BYTES_ALT, model="hy-image-3.5", **quota) + store._db.execute("UPDATE assets SET created=?, expires=? WHERE id=?", + (now - 3600, now - 3600 + 72 * 3600, first["id"])) + store._db.commit() + video = store.save_asset("video", MP4_BYTES, model="minimax-video-h3", **quota) + aurora = account_key("cn-cli", "100001", "") + store.create_task("seedvideotask-complete", aurora, "cn-cli", "minimax-video-h3") + store.update_task("seedvideotask-complete", status="completed", + media_id=video["id"], credit=12.5) + # Stays in progress until someone refreshes it; the simulated upstream knows this id. + store.create_task(SEED_VIDEO_TASK_ID, aurora, "cn-cli", "hunyuan-video-art") + finally: + store.close() diff --git a/preview/upstream.py b/preview/upstream.py index 2475f8d..a4d637c 100644 --- a/preview/upstream.py +++ b/preview/upstream.py @@ -19,16 +19,17 @@ import httpx +from app import media_generation from app.site_routing import DOMAIN_PROFILES, PROFILE_DOMAINS -from preview.seed import ACCOUNTS, CN_MODELS, DAY, INTL_MODELS, Account - +from preview.seed import (ACCOUNTS, CN_MODELS, COS_HOST, DAY, INTL_MODELS, MP4_BYTES, PNG_BYTES, + SEED_VIDEO_TASK_ID, Account) DIRECTIVE = re.compile(r"\[preview:(\w+)(?:=([\w.-]+))?\]") TOKEN = re.compile(r"preview-([a-z0-9]+)-(\d+|refresh)") TRAVEL_SECONDS = 180 ARCHIVE_CHUNK = 64 * 1024 DAILY_TRIPS = 3 LOGIN_SECONDS = 4 - +VIDEO_SECONDS = 6 # Preview video tasks complete fast enough to demo end to end. # Rates are per-model multipliers in the catalog's own "x1.00" format. MODELS = { "glm-5.1": {"name": "GLM-5.1", "vendor": "Zhipu", "credits": "x0.30", "maxInputTokens": 200000, @@ -142,6 +143,10 @@ def __init__(self, accounts=ACCOUNTS): self._missing: set = set() self._guests = itertools.count(1) self._logins: dict = {} + # One in-progress task seeded on disk that this simulation can finish on demand. + self._video_tasks = {SEED_VIDEO_TASK_ID: { + "owner": "aurora", "model": "hunyuan-video-art", + "at": time.time() - VIDEO_SECONDS - 1, "charged": False, "url": None}} self.states = {account.key: self._state_for(account) for account in accounts} self.routes = { "/v2/plugin/auth/token/refresh": self.refresh, @@ -160,6 +165,10 @@ def __init__(self, accounts=ACCOUNTS): "/console/chat/completions": self.chat, "/agenttool/v1/search": self.search, "/agenttool/v1/webfetch": self.webfetch, + "/v2/images/generations": self.images, + "/v2/images/edits": self.images, + "/v2/videos/generations": self.video_submit, + "/v2/videos/tasks": self.video_tasks, "/activity/growth/buddy/info": self.buddy_info, "/activity/growth/buddy/travel/status": self.travel_status, "/activity/growth/buddy/travel/config": self.travel_config, @@ -212,6 +221,8 @@ def dispatch(self, request: httpx.Request, raw: bytes) -> Reply: return self.releases() if path.endswith(".tar.gz"): return self.archive(request.url.host) + if request.url.host == COS_HOST: + return self.media_file(request) handler, arguments = self.routes.get(path), () if handler is None: handler, arguments = next(((h, m.groups()) for pattern, h in self.patterns @@ -292,9 +303,16 @@ def catalog(self, request, body, state): "descriptionZh": f"{MODELS[key]['name']}(预览数据)", "descriptionEn": f"{MODELS[key]['name']} (preview data)"} for key in state.account.models] + # Media models stay out of chat routing (supportsToolCall False) but join the + # account catalog so the gateway's media selection sees them as members. + models.extend({"id": name, "name": name, "supportsToolCall": False, + "tags": ["video" if name in media_generation.VIDEO_MODELS else "image"], + "credits": "x1.00"} + for name in state.account.media) + agents = [{"name": "cli", "tags": ["default"], + "models": list(state.account.models) + list(state.account.media)}] models[0]["isDefault"] = True - return ok({"models": models, "agents": [{"name": "cli", "tags": ["default"], - "models": list(state.account.models)}]}) + return ok({"models": models, "agents": agents}) def console_accounts(self, request, body, state): account = state.account @@ -422,6 +440,48 @@ def webfetch(self, request, body, state): "content_type": "text/markdown", "content_length": 64, "truncated": False, "status_code": 200}) + # -- media generation ---------------------------------------------------------------- + + def media_file(self, request): + """Serve the simulated COS bucket the media endpoints hand signed URLs for.""" + if request.url.path.endswith(".mp4"): + return Reply(chunks=[MP4_BYTES], media="video/mp4") + return Reply(chunks=[PNG_BYTES], media="image/png") + + def images(self, request, body, state): + model = str(body.get("model") or "") + if model in media_generation.VIDEO_MODELS: + return fail(500, "model family not served on this endpoint") + if model not in media_generation.IMAGE_TEXT_MODELS and model not in media_generation.IMAGE_EDIT_MODELS: + return fail(400, "route config not found") + count = max(1, min(int(body.get("n") or 1), 4)) + prompt = str(body.get("prompt") or "")[:60] + items = [{"url": f"https://{COS_HOST}/images/{uuid.uuid4().hex}.png", + "revised_prompt": f"预览图像:{prompt}"} for _ in range(count)] + return ok({"data": items, "usage": {"credit": None}}) + + def video_submit(self, request, body, state): + model = str(body.get("model") or "") + if model not in media_generation.VIDEO_MODELS: + return fail(400, "route config not found") + task_id = uuid.uuid4().hex + self._video_tasks[task_id] = {"owner": state.account.key, "model": model, + "at": time.time(), "charged": False, "url": None} + return ok({"id": task_id}) + + def video_tasks(self, request, body, state): + task_id = str(body.get("task_id") or "") + task = self._video_tasks.get(task_id) + if task is None or task["owner"] != state.account.key: + return fail(404, "task not found") + if time.time() - task["at"] < VIDEO_SECONDS: + return ok({"status": "processing"}) + if not task["charged"]: + task["charged"] = True + task["url"] = f"https://{COS_HOST}/videos/{task_id}.mp4" + self._charge(state, 5.0) + return ok({"status": "completed", "data": [{"url": task["url"]}], "usage": {"credit": 5.0}}) + # -- growth center ------------------------------------------------------------------ def buddy_info(self, request, body, state): diff --git a/tests/test_media_generation.py b/tests/test_media_generation.py new file mode 100644 index 0000000..be181a8 --- /dev/null +++ b/tests/test_media_generation.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +"""Media generation contracts, storage, routing and endpoint behaviour.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. + +import base64 +import json +import struct +import tempfile +import time +import unittest +from unittest.mock import AsyncMock, Mock, patch +import zlib + +import httpx +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pathlib import Path as _Path + +import converter +from app import media_generation +from app.admin_api import install_admin +from app.control_store import ControlStore +from app.media_store import MediaStore +from tests import test_region_routing as fixtures + + +def _png(rgb=(0x7C, 0x66, 0xDC)): + def chunk(tag, data): + body = tag + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) + header = struct.pack(">IIBBBBB", 8, 8, 8, 2, 0, 0, 0) + raw = b"".join(b"\x00" + bytes(rgb) * 8 for _ in range(8)) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"") + + +PNG_BYTES = _png() +MP4_BYTES = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2" + b"\x00" * 512 +COS = "https://cos.example" +IMAGE_PATHS = ("/v2/images/generations", "/v2/images/edits") + + +class ContractTests(unittest.TestCase): + """Pure contract checks: lists, bodies, status mapping and URL validation.""" + + def test_static_kinds_and_defaults(self): + self.assertEqual(media_generation.model_kinds("hunyuan-image-v3.0"), ("image",)) + self.assertIn("edit", media_generation.model_kinds("hunyuan-image-v3.0-art")) + self.assertIn("image", media_generation.model_kinds("hunyuan-image-v3.0-art")) + self.assertEqual(media_generation.model_kinds("minimax-video-h3"), ("video",)) + self.assertEqual(media_generation.model_kinds("glm-5.1"), ()) + self.assertEqual(media_generation.DEFAULT_IMAGE_MODEL, "hunyuan-image-v3.0") + self.assertEqual(media_generation.DEFAULT_IMAGE_EDIT_MODEL, "hunyuan-image-v2.0-general-edit") + self.assertEqual(media_generation.DEFAULT_VIDEO_MODEL, "minimax-video-h3") + self.assertEqual(media_generation.MEDIA_PROFILES, frozenset({"cn-cli"})) + + def test_catalog_kinds_and_validation_set(self): + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["image", "beta"]}), + ("image", "edit")) + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["video"]}), ("video",)) + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": []}), ()) + self.assertEqual(media_generation.catalog_kinds({"id": "x"}), ()) + self.assertTrue(media_generation.is_media_model("kling-video-v3")) + self.assertTrue(media_generation.is_media_model( + "custom-image", [{"id": "custom-image", "tags": ["image"]}])) + self.assertFalse(media_generation.is_media_model( + "custom-image", [{"id": "custom-image", "tags": ["beta"]}])) + + def test_base_url_validator(self): + normalize = media_generation.normalize_media_base_url + self.assertEqual(normalize(""), "") + self.assertEqual(normalize(" "), "") + self.assertEqual(normalize("https://media.example.com/"), "https://media.example.com") + self.assertEqual(normalize("http://10.0.0.1:8787/base/"), "http://10.0.0.1:8787/base") + for bad in ("example.com", "ftp://x", "https://user:pw@example.com", + "https://example.com?a=1", "https://example.com#f", "//example.com"): + with self.assertRaises(ValueError, msg=bad): + normalize(bad) + + def test_image_body_shapes(self): + body = media_generation.image_request_body("hunyuan-image-v3.0", "a cat", size="1024x1024", n=2) + self.assertEqual(body, {"model": "hunyuan-image-v3.0", "prompt": "a cat", + "size": "1024x1024", "n": 2}) + body = media_generation.image_request_body("gemini-3.0-pro-image", "a cat", quality="high") + self.assertEqual(body["response_format"], "b64_json") + self.assertEqual(body["quality"], "high") + self.assertNotIn("n", body) + body = media_generation.image_request_body( + "hunyuan-image-v2.0-general-edit", "edit", n=1, + images=["https://cos.example/a.png", "data:image/png;base64,AAA"], + input_fidelity="high") + self.assertEqual(body["image"], ["https://cos.example/a.png", "data:image/png;base64,AAA"]) + self.assertEqual(body["input_fidelity"], "high") + with self.assertRaises(ValueError): + media_generation.image_request_body("hunyuan-image-v3.0", " ") + with self.assertRaises(ValueError): + media_generation.image_request_body("hunyuan-image-v3.0", "a cat", images=[]) + + def test_video_body_shapes(self): + body = media_generation.video_request_body( + "minimax-video-h3", prompt="dance", seconds=4, resolution="2K", + aspect_ratio="16:9", enable_audio=True, image_url="https://cos.example/a.png") + self.assertEqual(body["watermark"], True) + self.assertEqual(body["seconds"], 4) + self.assertEqual(body["extra_parameters"], {"resolution": "2K", "aspect_ratio": "16:9", + "enable_audio": True}) + self.assertEqual(body["image_url"], "https://cos.example/a.png") + minimal = media_generation.video_request_body("minimax-video-h3", image_url="https://x/y.png") + self.assertNotIn("prompt", minimal) + self.assertNotIn("extra_parameters", minimal) + for bad in ({"seconds": 0}, {"seconds": 1.5}, {"resolution": "1080P"}): + with self.assertRaises(ValueError, msg=bad): + media_generation.video_request_body( + "minimax-video-h3", prompt="x", + seconds=bad.get("seconds"), resolution=bad.get("resolution")) + + def test_status_mapping_and_parsers(self): + self.assertEqual(media_generation.map_video_status("processing"), "in_progress") + self.assertEqual(media_generation.map_video_status("COMPLETED"), "completed") + self.assertEqual(media_generation.map_video_status("failed"), "failed") + self.assertEqual(media_generation.map_video_status("weird-state"), "in_progress") + self.assertEqual(media_generation.map_video_status(None), "in_progress") + items, credit = media_generation.parse_image_reply( + {"data": {"data": [{"url": "https://a/1.png", "junk": 1}], "usage": {"credit": 0.5}}}) + self.assertEqual(items, [{"url": "https://a/1.png"}]) + self.assertEqual(credit, 0.5) + items, credit = media_generation.parse_image_reply( + {"data": [{"b64_json": "AAA", "revised_prompt": "cat"}]}) + self.assertEqual(items, [{"b64_json": "AAA", "revised_prompt": "cat"}]) + self.assertIsNone(credit) + with self.assertRaises(ValueError): + media_generation.parse_image_reply({"data": {"data": []}}) + self.assertEqual(media_generation.parse_video_submit({"data": {"id": " t1 "}}), "t1") + with self.assertRaises(ValueError): + media_generation.parse_video_submit({"data": {}}) + status, urls, credit, message = media_generation.parse_video_task( + {"data": {"status": "failed", "error": "blocked", "usage": {"credit": 2}}}) + self.assertEqual((status, urls, credit, message), ("failed", [], 2.0, "blocked")) + status, urls, credit, message = media_generation.parse_video_task( + {"data": {"status": "completed", "data": [{"url": "https://a/v.mp4"}]}}) + self.assertEqual((status, urls), ("completed", ["https://a/v.mp4"])) + self.assertIsNone(credit) + + +class StoreTests(unittest.TestCase): + """MediaStore sniffing, eviction, TTL and pagination.""" + + def setUp(self): + self.root = _Path(self.enterContext(tempfile.TemporaryDirectory())) + self.store = MediaStore(self.root) + self.addCleanup(self.store.close) + + def save(self, kind, data, **kwargs): + kwargs.setdefault("ttl_hours", 72) + kwargs.setdefault("max_total_bytes", 1 << 30) + return self.store.save_asset(kind, data, **kwargs) + + def test_sniff_whitelist(self): + for kind, data, ext in (("image", PNG_BYTES, "png"), + ("image", b"\xff\xd8\xffrest", "jpg"), + ("image", b"RIFF----WEBPdata", "webp"), + ("image", b"GIF89a....", "gif"), + ("video", MP4_BYTES, "mp4"), + ("video", b"\x1a\x45\xdf\xa3video", "webm")): + meta = self.save(kind, data) + self.assertTrue(meta["id"].startswith("img_" if kind == "image" else "vid_")) + self.assertEqual(len(meta["id"]), 4 + 32) + self.assertEqual(meta["kind"], kind) + self.assertTrue(self.store.asset_file(meta).name.endswith(ext), ext) + for rejected in (b"x", b"", b"PNG-no-magic", MP4_BYTES[:2]): + with self.assertRaises(ValueError): + self.save("image", rejected) + # A video payload must not land as an image asset even with a valid header. + with self.assertRaises(ValueError): + self.save("image", MP4_BYTES) + with self.assertRaises(ValueError): + self.save("video", PNG_BYTES) + + def test_headers_and_etag(self): + meta = self.save("image", PNG_BYTES) + headers = __import__("app.media_store", fromlist=["security_headers"]).security_headers(meta) + self.assertEqual(headers["Content-Security-Policy"], "sandbox; default-src 'none'") + self.assertEqual(headers["X-Content-Type-Options"], "nosniff") + self.assertIn("immutable", headers["Cache-Control"]) + self.assertEqual(headers["ETag"], f'"{meta["sha256"]}"') + self.assertEqual(meta["sha256"], __import__("hashlib").sha256(PNG_BYTES).hexdigest()) + + def test_eviction_keeps_quota(self): + size = len(PNG_BYTES) + self.save("image", PNG_BYTES, max_total_bytes=2 * size) + first = self.store.list_assets()["items"][0]["id"] + self.save("image", PNG_BYTES, max_total_bytes=2 * size) + self.save("image", PNG_BYTES, max_total_bytes=2 * size) + usage = self.store.usage() + self.assertLessEqual(usage["total_bytes"], 2 * size + size) # New write fits after eviction. + self.assertEqual(usage["images"]["count"], 2) + self.assertIsNone(self.store.get_asset(first)) + + def test_ttl_cleanup_removes_assets_and_tasks(self): + meta = self.save("image", PNG_BYTES, ttl_hours=0.001) + self.store.create_task("t1", "acct", "cn-cli", "m") + self.store.update_task("t1", status="completed", media_id=meta["id"], credit=1) + time.sleep(0.02) + # Expire both rows directly: the TTL is expressed in hours. + self.store._db.execute("UPDATE assets SET expires=?", (time.time() - 1,)) + self.store._db.execute("UPDATE tasks SET created=?", (time.time() - 72 * 3600 - 1,)) + self.store._db.commit() + removed = self.store.cleanup(ttl_hours=72, max_total_bytes=1 << 30) + self.assertEqual(removed["expired"], 1) + self.assertIsNone(self.store.get_asset(meta["id"])) + self.assertIsNone(self.store.get_task("t1")) + + def test_delete_clears_bound_tasks(self): + meta = self.save("video", MP4_BYTES) + self.store.create_task("t1", "acct", "cn-cli", "m") + self.store.update_task("t1", status="completed", media_id=meta["id"]) + self.assertTrue(self.store.delete_asset(meta["id"])) + task = self.store.get_task("t1") + self.assertEqual(task["status"], "completed") + self.assertIsNone(task["media_id"]) + self.assertFalse(self.store.delete_asset(meta["id"])) + + def test_pagination_cursors(self): + for _ in range(3): + self.save("image", PNG_BYTES) + page = self.store.list_assets(limit=2) + self.assertEqual(len(page["items"]), 2) + self.assertTrue(page["has_more"]) + second = self.store.list_assets(kind="image", cursor=page["next_cursor"], limit=2) + self.assertEqual(len(second["items"]), 1) + self.assertFalse(second["has_more"]) + ids = [item["id"] for item in page["items"]] + [item["id"] for item in second["items"]] + self.assertEqual(len(set(ids)), 3) + with self.assertRaises(ValueError): + self.store.list_assets(cursor="not-json") + with self.assertRaises(ValueError): + self.store.list_tasks(status="bogus") + + +class MediaRoutingTests(fixtures.RegionRoutingTests): + """Endpoint behaviour against a mocked media upstream.""" + + def setUp(self): + super().setUp() + self.allowed_profiles = {"cn-cli"} + self.media_root = _Path(self.enterContext(tempfile.TemporaryDirectory())) + self.store = MediaStore(self.media_root) + self.addCleanup(self.store.close) + self.media_requests = [] + self.image_status = 200 + self.image_statuses = [] # Per-request override; pops take priority over image_status. + self.video_task_status = "completed" + self.enterContext(patch.dict(converter.CONFIG, { + "media_store": self.store, "media_public_base_url": "", + "media_max_total_mb": 1024, "media_ttl_hours": 72})) + self.enterContext(patch.object(converter, "_log", side_effect=lambda *a, **k: None)) + + def entry_state(self, uid): + # pool.entries() copies rows; reread to observe cooldown changes. + return next(entry for entry in self.pool.entries() + if entry["cm"].summary()["uid"] == uid) + + def configure_media(self, *, profiles=("cn-cli", "cn-work", "intl-cli", "intl-work"), **kwargs): + self.configure(profiles=profiles, **kwargs) + + def handle_upstream(self, request): + path = request.url.path + if request.url.host == "cos.example": + content = MP4_BYTES if path.endswith(".mp4") else PNG_BYTES + return httpx.Response(200, content=content) + if path in IMAGE_PATHS: + self.media_requests.append(request) + status = self.image_statuses.pop(0) if self.image_statuses else self.image_status + if status != 200: + return httpx.Response(status, json={"error": {"message": "synthetic media failure"}}) + return httpx.Response(200, json={"data": {"data": [ + {"url": f"{COS}/image-0.png", "revised_prompt": "synthetic"}], + "usage": {"credit": None}}}) + if path == "/v2/videos/generations": + self.media_requests.append(request) + body = json.loads(request.read()) + if body.get("model") in media_generation.IMAGE_TEXT_MODELS + media_generation.IMAGE_EDIT_MODELS: + return httpx.Response(400, json={"error": {"message": "route config not found"}}) + return httpx.Response(200, json={"data": {"id": "task-synthetic-1"}}) + if path == "/v2/videos/tasks": + self.media_requests.append(request) + if self.video_task_status == "failed": + return httpx.Response(200, json={"data": {"status": "failed", "error": "content blocked"}}) + if self.video_task_status == "processing": + return httpx.Response(200, json={"data": {"status": "processing"}}) + return httpx.Response(200, json={"data": { + "status": "completed", "data": [{"url": f"{COS}/video-1.mp4"}], + "usage": {"credit": 62.85}}}) + return super().handle_upstream(request) + + # -- images -------------------------------------------------------------------------- + + def test_image_generation_returns_b64_without_base_url(self): + result = self.client.post("/v1/images/generations", json={"prompt": "a cat"}) + self.assertEqual(result.status_code, 200, result.text) + body = result.json() + self.assertNotIn("usage", body) # Omitted when the upstream reports no credit. + item = body["data"][0] + self.assertIn("b64_json", item) + self.assertEqual(item["revised_prompt"], "synthetic") + self.assertTrue(base64.b64decode(item["b64_json"]).startswith(b"\x89PNG")) + self.assertEqual(self.store.usage()["images"]["count"], 0) # No local writes without a base URL. + + def test_image_generation_persists_with_base_url(self): + self.enterContext(patch.dict(converter.CONFIG, {"media_public_base_url": "https://media.example.com/"})) + result = self.client.post("/v1/images/generations", json={"prompt": "a cat", "n": 1}) + self.assertEqual(result.status_code, 200, result.text) + url = result.json()["data"][0]["url"] + self.assertTrue(url.startswith("https://media.example.com/v1/media/images/img_"), url) + served = self.client.get(url.removeprefix("https://media.example.com")) + self.assertEqual(served.status_code, 200) + self.assertEqual(served.headers["content-type"], "image/png") + self.assertEqual(served.headers["content-security-policy"], "sandbox; default-src 'none'") + self.assertEqual(served.headers["x-content-type-options"], "nosniff") + self.assertIn("immutable", served.headers["cache-control"]) + etag = served.headers["etag"] + again = self.client.get(url.removeprefix("https://media.example.com"), + headers={"If-None-Match": etag}) + self.assertEqual(again.status_code, 304) + # Explicit b64_json skips persistence even when a base URL is configured. + explicit = self.client.post("/v1/images/generations", + json={"prompt": "a cat", "response_format": "b64_json"}) + self.assertIn("b64_json", explicit.json()["data"][0]) + self.assertEqual(self.store.usage()["images"]["count"], 1) + + def test_image_edit_shapes_upstream_body(self): + result = self.client.post("/v1/images/edits", json={ + "prompt": "make it blue", "images": [{"image_url": f"{COS}/source.png"}]}) + self.assertEqual(result.status_code, 200, result.text) + sent = json.loads(self.media_requests[-1].read()) + self.assertEqual(sent["model"], media_generation.DEFAULT_IMAGE_EDIT_MODEL) + self.assertEqual(sent["image"], [f"{COS}/source.png"]) + self.assertEqual(self.media_requests[-1].url.path, "/v2/images/edits") + + def test_image_validation_errors(self): + cases = [ + ({"prompt": "x", "model": "not-a-model"}, "model_not_found"), + ({"prompt": "x", "model": "minimax-video-h3"}, "model_not_found"), + ({"prompt": "x", "n": 9}, None), + ({"prompt": " ", "model": "hunyuan-image-v3.0"}, None), + ({"prompt": "x", "response_format": "png"}, None), + ({"images": [], "prompt": "x"}, None), + ] + for payload, code in cases: + path = "/v1/images/edits" if "images" in payload else "/v1/images/generations" + result = self.client.post(path, json=payload) + self.assertEqual(result.status_code, 400, (payload, result.text)) + if code: + self.assertEqual(result.json()["error"]["code"], code) + self.assertEqual(self.media_requests, []) + + def test_media_models_are_rejected_by_chat(self): + for path, payload in ( + ("/v1/chat/completions", {"model": "hunyuan-image-v3.0", "messages": [{"role": "user", "content": "hi"}]}), + ("/v1/messages", {"model": "minimax-video-h3", "messages": [{"role": "user", "content": "hi"}]}), + ("/v1/responses", {"model": "kling-video-v3", "input": "hi"})): + result = self.client.post(path, json=payload) + self.assertEqual(result.status_code, 400, (path, result.text)) + self.assertEqual(result.json()["error"]["code"], "media_model_not_chat") + + def test_api_key_guards_media_endpoints(self): + self.enterContext(patch.dict(converter.CONFIG, {"api_key": "secret"})) + self.assertEqual(self.client.post("/v1/images/generations", json={"prompt": "x"}).status_code, 401) + self.assertEqual(self.client.get("/v1/videos/any").status_code, 401) + + # -- videos -------------------------------------------------------------------------- + + def test_video_lifecycle(self): + self.video_task_status = "processing" + submitted = self.client.post("/v1/videos", json={"prompt": "dance", "seconds": "4", + "resolution": "768P"}) + self.assertEqual(submitted.status_code, 200, submitted.text) + task = submitted.json() + self.assertEqual(task["object"], "video") + self.assertEqual(task["status"], "queued") + self.assertEqual(task["model"], media_generation.DEFAULT_VIDEO_MODEL) + sent = json.loads(self.media_requests[-1].read()) + self.assertEqual(sent["seconds"], 4) + self.assertEqual(sent["extra_parameters"]["resolution"], "768P") + self.assertEqual(sent["watermark"], True) + + first = self.client.get(f"/v1/videos/{task['id']}").json() + self.assertEqual(first["status"], "in_progress") + + self.video_task_status = "completed" + completed = self.client.get(f"/v1/videos/{task['id']}").json() + self.assertEqual(completed["status"], "completed") + self.assertNotIn("video", completed) # No base URL configured yet. + self.assertIn("media_public_base_url", completed["message"]) + self.assertEqual(completed["usage"], {"credit": 62.85}) + self.assertEqual(self.store.usage()["videos"]["count"], 1) # Video downloads always persist. + + # Configuring the base URL later surfaces the link without another upstream query. + self.enterContext(patch.dict(converter.CONFIG, {"media_public_base_url": "https://media.example.com"})) + again = self.client.get(f"/v1/videos/{task['id']}").json() + self.assertTrue(again["video"]["url"].startswith("https://media.example.com/v1/media/videos/vid_")) + queries = [r for r in self.media_requests if r.url.path == "/v2/videos/tasks"] + self.assertEqual(len(queries), 2) # In-progress check and the completed check. + + def test_video_content_redirect_and_missing_base(self): + result = self.client.post("/v1/videos", json={"prompt": "dance"}) + task_id = result.json()["id"] + missing = self.client.get(f"/v1/videos/{task_id}/content") + self.assertEqual(missing.status_code, 409) + self.assertEqual(missing.json()["error"]["code"], "media_base_url_missing") + self.enterContext(patch.dict(converter.CONFIG, {"media_public_base_url": "https://media.example.com"})) + self.client.get(f"/v1/videos/{task_id}") # Complete and persist the video. + redirected = self.client.get(f"/v1/videos/{task_id}/content", follow_redirects=False) + self.assertEqual(redirected.status_code, 302) + self.assertTrue(redirected.headers["location"].startswith( + "https://media.example.com/v1/media/videos/vid_")) + + def test_video_failure_is_terminal(self): + self.video_task_status = "failed" + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + first = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(first["status"], "failed") + self.assertEqual(first["error"]["message"], "content blocked") + repeat = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(repeat, first) + queries = [r for r in self.media_requests if r.url.path == "/v2/videos/tasks"] + self.assertEqual(len(queries), 1) + + def test_video_submission_uses_bound_account_after_disable(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + # Cooldown must not block queries on the bound account. + entry = self.entries["cn-cli"] + self.pool.cooldown(entry["cm"], reason="synthetic breaker") + refreshed = self.client.get(f"/v1/videos/{task_id}") + self.assertEqual(refreshed.status_code, 200, refreshed.text) + self.assertEqual(refreshed.json()["status"], "completed") + + def test_video_task_account_removed_reports_conflict(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.configure_media(profiles=("cn-work", "intl-cli", "intl-work")) + result = self.client.get(f"/v1/videos/{task_id}") + self.assertEqual(result.status_code, 409) + self.assertEqual(result.json()["error"]["code"], "account_removed") + + def test_video_validation(self): + cases = [ + {"model": "hunyuan-image-v3.0"}, + {"prompt": "x", "seconds": 0}, + {"prompt": "x", "resolution": "1080P"}, + {"seconds": 4}, + {}, + ] + for payload in cases: + result = self.client.post("/v1/videos", json=payload) + self.assertEqual(result.status_code, 400, (payload, result.text)) + self.assertEqual([r for r in self.media_requests if r.url.path == "/v2/videos/generations"], []) + + def test_deleted_video_asset_reports_without_redownload(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.client.get(f"/v1/videos/{task_id}") + task = self.store.get_task(task_id) + self.store.delete_asset(task["media_id"]) + result = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(result["status"], "completed") + self.assertNotIn("video", result) + self.assertIn("已删除", result["message"]) + + # -- selection and failover ------------------------------------------------------------ + + def test_media_account_requires_profile_catalog_and_credit(self): + # Only intl accounts exist: no whitelisted media profile. + self.configure_media(profiles=("intl-cli", "intl-work")) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 503) + self.assertEqual(result.json()["error"]["code"], "no_media_account") + # Whitelisted profile but zero balance. + self.configure_media(balances={"cn-cli": 0}) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 503) + # Catalog exists but does not list the model. + self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 503) + # Restore the balance (the pool rebuild also clears account catalogs). + self.configure_media(balances={"cn-cli": 100}) + # A tagged catalog entry qualifies even outside the static list. + self.account_catalogs({"cn-cli": [ + fixtures.model("shared-model"), + {"id": "custom-video-model", "supportsToolCall": False, "tags": ["video"]}]}) + self.assertEqual(self.client.post("/v1/videos", json={"prompt": "x", "model": "custom-video-model", + "seconds": 4}).status_code, 200) + + def test_no_replay_on_server_errors(self): + self.image_status = 500 + def test_no_replay_on_server_errors(self): + self.enterContext(patch.dict(converter.CONFIG, {"failover_max": 2})) + self.image_status = 500 + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 500) + self.assertEqual(len(self.media_requests), 1) + + def test_replay_on_explicit_refusals_and_cooldown_isolation(self): + self.add_account("cn2", "cn-cli") + self.configure_media(profiles=("cn-cli", "cn2", "cn-work", "intl-cli", "intl-work")) + self.enterContext(patch.dict(converter.CONFIG, {"failover_max": 2})) + # A 403 replays but never cools the account down. + self.image_statuses = [403] + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + self.assertEqual(len(self.media_requests), 2) + for uid in ("cn-cli", "cn2"): + self.assertLess(self.entry_state(uid)["fail_until"], time.time(), uid) + # A 401 replays the same way and does cool the first account down. + self.image_statuses = [401] + first_uid = self.media_requests[-2].headers["x-user-id"] # First attempt of the 401 pair. + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + self.assertGreater(self.entry_state(first_uid)["fail_until"], time.time()) + # 401 with no replacement account left surfaces the original status. + self.image_status = 401 + self.configure_media(profiles=("cn-cli", "cn-work", "intl-cli", "intl-work")) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 401) + + + def test_admin_media_view_lists_models_and_usage(self): + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + models = converter.admin_media_models() + by_id = {entry["id"]: entry for entry in models} + self.assertEqual(by_id["hunyuan-image-v3.0"]["kinds"], ["image"]) + self.assertEqual(by_id["hunyuan-image-v3.0"]["default"], ["image"]) + self.assertEqual(by_id["hunyuan-image-v3.0"]["sources"], ["static"]) + self.assertGreaterEqual(by_id["hunyuan-image-v3.0"]["accounts"], 1) + self.assertEqual(by_id["hunyuan-image-v2.0-general-edit"]["default"], ["edit"]) + self.assertEqual(by_id["minimax-video-h3"]["default"], ["video"]) + self.assertNotIn("glm-5.1", by_id) + + +class AdminMediaApiTests(unittest.TestCase): + """/admin/media routes through the installed management API.""" + + def setUp(self): + self.root = _Path(self.enterContext(tempfile.TemporaryDirectory())) + self.store = MediaStore(self.root) + self.addCleanup(self.store.close) + self.control = ControlStore(self.root / "control.sqlite3") + self.addCleanup(self.control.close) + self.config = {"api_key": "synthetic-key", "control_store": self.control, + "media_store": self.store, "media_public_base_url": "https://media.example.com", + "media_max_total_mb": 1024, "media_ttl_hours": 72, + "session_path": self.root / "admin-sessions.json"} + audit = Mock() + audit.storage.return_value = {"db_bytes": 0} + self.config["audit_store"] = audit + self.video_task = {"task_id": "t1", "account_key": "acct", "profile": "cn-cli", + "model": "minimax-video-h3", "created": time.time(), + "updated": time.time(), "status": "completed", + "media_id": None, "error": None, "credit": 3.5} + self.gateway = Mock() + self.gateway.admin_media_models.return_value = [{"id": "minimax-video-h3", "kinds": ["video"], + "sources": ["static"], "default": ["video"], + "accounts": 2}] + self.gateway._media_video_refresh = AsyncMock(return_value=self.video_task) + self.gateway._media_video_response = Mock( + side_effect=lambda task: {"id": task["task_id"], "status": task["status"]}) + self.app = FastAPI() + install_admin(self.app, self.config, self.gateway) + self.client = self.enterContext(TestClient(self.app)) + session = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + headers={"Origin": "http://testserver"}) + self.headers = {"Origin": "http://testserver", "X-CSRF-Token": session.json()["csrf_token"]} + + def test_summary_requires_session_and_reports_state(self): + anonymous = TestClient(self.app) # The shared client keeps its session cookie. + self.assertEqual(anonymous.get("/admin/media").status_code, 401) + anonymous.close() + result = self.client.get("/admin/media", headers=self.headers) + self.assertEqual(result.status_code, 200, result.text) + body = result.json() + self.assertTrue(body["configured"]) + self.assertEqual(body["base_url"], "https://media.example.com") + self.assertFalse(body["locked"]) + self.assertEqual(body["limit_bytes"], 1024 * 1048576) + self.assertEqual(body["tasks"], {"active": 0, "failed": 0}) + self.assertEqual(body["models"][0]["id"], "minimax-video-h3") + + def test_asset_listing_serving_and_delete(self): + meta = self.store.save_asset("image", PNG_BYTES, model="m", ttl_hours=1, max_total_bytes=1 << 20) + listed = self.client.get("/admin/media/assets", headers=self.headers).json() + self.assertEqual(listed["items"][0]["public_url"], + f"https://media.example.com/v1/media/images/{meta['id']}") + served = self.client.get(f"/admin/media/assets/{meta['id']}/content", headers=self.headers) + self.assertEqual(served.status_code, 200) + self.assertEqual(served.headers["content-security-policy"], "sandbox; default-src 'none'") + downloaded = self.client.get(f"/admin/media/assets/{meta['id']}/content?download=1", + headers=self.headers) + self.assertIn("attachment", downloaded.headers["content-disposition"]) + missing = self.client.get("/admin/media/assets/img_doesnotexist/content", headers=self.headers) + self.assertEqual(missing.status_code, 404) + deleted = self.client.delete(f"/admin/media/assets/{meta['id']}", headers=self.headers) + self.assertEqual(deleted.status_code, 200) + self.assertEqual(self.client.get("/admin/media/assets", headers=self.headers) + .json()["items"], []) + self.assertEqual( + self.client.get("/admin/media/assets?kind=bogus", headers=self.headers).status_code, 400) + + def test_task_listing_and_refresh(self): + self.store.create_task("t1", "acct", "cn-cli", "minimax-video-h3") + listed = self.client.get("/admin/media/tasks", headers=self.headers).json() + self.assertEqual(listed["items"][0]["task_id"], "t1") + refreshed = self.client.post("/admin/media/tasks/t1/refresh", headers=self.headers) + self.assertEqual(refreshed.status_code, 200, refreshed.text) + self.gateway._media_video_refresh.assert_called_once() + missing = self.client.post("/admin/media/tasks/missing/refresh", headers=self.headers) + self.gateway._media_video_refresh.side_effect = converter.HTTPException(status_code=404, detail={ + "error": {"message": "视频任务不存在或已过期"}}) + missing = self.client.post("/admin/media/tasks/missing/refresh", headers=self.headers) + self.assertEqual(missing.status_code, 404) + self.assertIn("视频任务不存在", missing.json()["error"]["message"]) + + def test_unavailable_store_reports_service_error(self): + self.config["media_store"] = None + result = self.client.get("/admin/media/assets", headers=self.headers) + self.assertEqual(result.status_code, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/webui_fixture.py b/tests/webui_fixture.py index e39e383..4de9e98 100644 --- a/tests/webui_fixture.py +++ b/tests/webui_fixture.py @@ -17,11 +17,14 @@ import converter as gateway from app.audit_store import AuditStore from app.control_store import ControlStore +from app.media_store import MediaStore +from app.runtime_management import install, close from app.gateway_management import Management from app.runtime_management import install, close from app.settings import apply_persisted_settings from app.updates import Updater +FIXTURE_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x08\x00\x00\x00\x08\x08\x02\x00\x00\x00{\x14\xf6u\x00\x00\x00 IDAT\x08\x1dc`\x00\x00\x00\x82\x00\x81\xb5s\x1cJ\x00\x00\x00\x00IEND\xaeB`\x82" @asynccontextmanager async def fake_backend(url, headers, body, **kwargs): @@ -67,6 +70,9 @@ def main(): identity = pool.entries()[0]["account_key"] gateway.CONFIG.update(ledger=ledger, model_cache=None, account_catalogs={identity: { "profile": "cn-work", "models": [{"id": "fixture-model", "supportsToolCall": True, "credits": "x0.00"}]}}) + media = MediaStore(root) + gateway.CONFIG["media_store"] = media + media.save_asset("image", FIXTURE_PNG, model="fixture-model", ttl_hours=72, max_total_bytes=1 << 30) pool._sync_pending.clear() pool._sync_event.clear() gateway.CONFIG["management"] = Management(gateway) diff --git a/web/e2e/backend.integration.ts b/web/e2e/backend.integration.ts index cb48b2d..4d9d48a 100644 --- a/web/e2e/backend.integration.ts +++ b/web/e2e/backend.integration.ts @@ -147,3 +147,48 @@ test("manual trial uses the real admin API and persists success and failed-attem ).toBe(false); expect(errors).toEqual([]); }); + +test("real management API: media summary, settings round-trip and asset CSP", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await page.getByLabel("API key", { exact: true }).fill("synthetic-e2e-key"); + await page.getByRole("button", { name: "进入工作台" }).click(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + + const summary = await (await page.request.get("/admin/media")).json(); + expect(summary.available).toBe(true); + expect(summary.configured).toBe(false); + expect(summary.models.some((model: { id: string }) => model.id === "hunyuan-image-v3.0")).toBe( + true, + ); + + const session = await (await page.request.get("/admin/session")).json(); + const settings = await (await page.request.get("/admin/settings")).json(); + const saved = await page.request.patch("/admin/settings", { + headers: { Origin: "http://127.0.0.1:5175", "X-CSRF-Token": session.csrf_token }, + data: { + revision: settings.revision, + values: { media_public_base_url: "https://media.example.com" }, + }, + }); + expect(saved.status()).toBe(200); + const after = await (await page.request.get("/admin/media")).json(); + expect(after.configured).toBe(true); + expect(after.base_url).toBe("https://media.example.com"); + + const listed = await (await page.request.get("/admin/media/assets")).json(); + expect(listed.items.length).toBeGreaterThan(0); + const asset = listed.items[0]; + expect(asset.public_url).toContain("/v1/media/images/"); + const content = await page.request.get(`/admin/media/assets/${asset.id}/content`); + expect(content.status()).toBe(200); + expect(content.headers()["content-security-policy"]).toBe("sandbox; default-src 'none'"); + expect(content.headers()["x-content-type-options"]).toBe("nosniff"); + // The public read route serves the same bytes without any session or key. + const publicRead = await page.request.get(`/v1/media/images/${asset.id}`); + expect(publicRead.status()).toBe(200); + expect(publicRead.headers()["content-security-policy"]).toBe("sandbox; default-src 'none'"); + expect((await publicRead.body()).length).toBe(asset.size); + expect(errors).toEqual([]); +}); diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts index b9e4286..a296925 100644 --- a/web/e2e/layout.spec.ts +++ b/web/e2e/layout.spec.ts @@ -104,3 +104,70 @@ test.describe("narrow layout", () => { expect(portaled).toBe(true); }); }); + +test.describe("media page", () => { + const mediaSummary = { + configured: false, + base_url: "", + locked: false, + available: true, + limit_bytes: 1073741824, + ttl_hours: 72, + usage: { + total_bytes: 327155712, + images: { count: 2, bytes: 100663296 }, + videos: { count: 1, bytes: 226492416 }, + }, + tasks: { active: 1, failed: 0 }, + models: [], + }; + const mediaAssets = { + items: [ + { + id: `img_${"a".repeat(32)}`, + kind: "image", + mime: "image/png", + size: 4096, + sha256: "0".repeat(64), + model: "hunyuan-image-v3.0", + created: 1789000000, + expires: 1789000000 + 71 * 3600, + }, + ], + has_more: false, + next_cursor: null, + }; + + async function openMedia(page: Page) { + await page.route("**/admin/**", async (route) => { + const url = new URL(route.request().url()); + const send = (json: unknown) => route.fulfill({ json, status: 200 }); + if (url.pathname === "/admin/session") return send({ authenticated: true, csrf_token: "cs" }); + if (url.pathname === "/admin/settings") return send(settings); + if (url.pathname === "/admin/media") return send(mediaSummary); + if (url.pathname === "/admin/media/assets") return send(mediaAssets); + return send({}); + }); + await page.goto("/dashboard/media"); + } + + test("cards reflow inside a 600px viewport", async ({ page }) => { + await page.setViewportSize({ width: 600, height: 900 }); + await openMedia(page); + const overflow = await page.evaluate(() => { + const scroller = document.scrollingElement!; + return scroller.scrollWidth - scroller.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.getByRole("button", { name: /hunyuan-image-v3\.0/ })).toBeVisible(); + await page.screenshot({ path: "test-results/media-600px.png", fullPage: true }); + }); + + test("dark mode keeps the card grid readable", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await openMedia(page); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.getByRole("button", { name: /hunyuan-image-v3\.0/ })).toBeVisible(); + await page.screenshot({ path: "test-results/media-dark.png", fullPage: true }); + }); +}); diff --git a/web/e2e/media.spec.ts b/web/e2e/media.spec.ts new file mode 100644 index 0000000..8500047 --- /dev/null +++ b/web/e2e/media.spec.ts @@ -0,0 +1,210 @@ +import { expect, test, type Page } from "@playwright/test"; + +// Mocked management API: sidebar entry, asset grid, preview drawer and the settings drawer. +const now = Date.now() / 1000; + +function summary(configured: boolean, locked = false) { + return { + configured, + base_url: configured ? "https://media.example.com" : "", + locked, + available: true, + limit_bytes: 1073741824, + ttl_hours: 72, + usage: { + total_bytes: 327155712, + images: { count: 2, bytes: 100663296 }, + videos: { count: 1, bytes: 226492416 }, + }, + tasks: { active: 1, failed: 1 }, + models: [ + { + id: "hunyuan-image-v3.0", + kinds: ["image"], + sources: ["static"], + default: ["image"], + accounts: 2, + }, + { + id: "minimax-video-h3", + kinds: ["video"], + sources: ["static"], + default: ["video"], + accounts: 0, + }, + ], + }; +} + +function asset(id: string, kind: "image" | "video", model: string, configured: boolean) { + return { + id, + kind, + mime: kind === "video" ? "video/mp4" : "image/png", + size: 4096, + sha256: "0".repeat(64), + model, + created: now - 3600, + expires: now + 71 * 3600, + ...(configured ? { public_url: `https://media.example.com/v1/media/${kind}s/${id}` } : {}), + }; +} + +const settingsItems = (locked: boolean) => [ + { + key: "media_public_base_url", + label: "资源访问地址", + type: "string", + value: "", + stored: null, + source: locked ? "env" : "default", + mode: "hot", + locked, + }, + { + key: "media_max_total_mb", + label: "媒体容量上限(MB)", + type: "integer", + value: 1024, + stored: null, + source: "default", + mode: "hot", + locked: false, + min: 64, + max: 1048576, + }, + { + key: "media_ttl_hours", + label: "媒体保留时长(小时)", + type: "integer", + value: 72, + stored: null, + source: "default", + mode: "hot", + locked: false, + min: 1, + max: 8760, + }, +]; + +async function mockMedia(page: Page, options: { configured?: boolean; locked?: boolean } = {}) { + const configured = options.configured === true; + const patches: unknown[] = []; + await page.route("**/admin/**", async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const send = (json: unknown, status = 200) => route.fulfill({ json, status }); + if (path === "/admin/session") return send({ authenticated: true, csrf_token: "cs" }); + if (path === "/admin/media") return send(summary(configured, options.locked === true)); + if (path === "/admin/media/assets") { + const items = [ + asset("img_" + "a".repeat(32), "image", "hunyuan-image-v3.0", configured), + asset("img_" + "b".repeat(32), "image", "hy-image-3.5", configured), + asset("vid_" + "c".repeat(32), "video", "minimax-video-h3", configured), + ]; + return send({ items, has_more: false, next_cursor: null }); + } + if (path === "/admin/media/tasks") { + return send({ + items: [ + { + task_id: "task-in-progress", + account_key: "fingerprint", + profile: "cn-cli", + model: "minimax-video-h3", + created: now - 600, + updated: now - 300, + status: "in_progress", + media_id: null, + error: null, + credit: null, + }, + ], + has_more: false, + next_cursor: null, + }); + } + if (path === "/admin/settings" && route.request().method() === "PATCH") { + patches.push(route.request().postDataJSON()); + return send({ + revision: 2, + items: settingsItems(false), + audit: { db_bytes: 0 }, + session: {}, + }); + } + if (path === "/admin/settings") + return send({ + revision: 1, + items: settingsItems(options.locked === true), + audit: { db_bytes: 0 }, + session: {}, + }); + if (path.startsWith("/admin/media/assets/img_") && !path.endsWith("content")) { + return send(asset("img_" + "a".repeat(32), "image", "hunyuan-image-v3.0", configured)); + } + return send({}); + }); + return { patches }; +} + +test("media page: grid, preview drawer and settings drawer validation", async ({ page }) => { + const { patches } = await mockMedia(page); + await page.goto("/dashboard/media"); + await expect(page.getByRole("heading", { name: "媒体生成", exact: true })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText("未配置资源访问地址"); + await expect(page.getByRole("button", { name: /图片/ }).first()).toBeVisible(); + + // Cards open the preview drawer; copy-link stays hidden until a base URL exists. + await page + .getByRole("button", { name: /hunyuan-image-v3\.0/ }) + .first() + .click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("资源详情"); + await expect(dialog.getByRole("button", { name: "复制链接" })).toHaveCount(0); + const href = await dialog.getByRole("link", { name: "下载" }).getAttribute("href"); + expect(href).toContain("/admin/media/assets/img_"); + await dialog.getByRole("button", { name: "关闭抽屉" }).click(); + + await page.getByRole("tab", { name: "视频任务" }).click(); + await expect(page.getByRole("cell", { name: "task-in-progress" })).toBeVisible(); + await expect(page.getByRole("button", { name: "查询状态" })).toBeVisible(); + await page.getByRole("tab", { name: "模型" }).click(); + await expect(page.getByText("无可用账号")).toBeVisible(); + + await page.getByRole("button", { name: "媒体设置" }).click(); + const drawer = page.getByRole("dialog"); + await drawer.getByLabel("资源访问地址").fill("example.com"); + await expect(drawer.getByRole("alert")).toContainText("必须以 http:// 或 https:// 开头"); + await expect(drawer.getByRole("button", { name: "保存" })).toBeDisabled(); + expect(patches).toHaveLength(0); + await drawer.getByLabel("资源访问地址").fill("https://cdn.example.com/prefix/"); + await drawer.getByRole("button", { name: "保存" }).click(); + await expect(drawer).toHaveCount(0); + expect(patches[0]).toMatchObject({ + revision: 1, + values: { media_public_base_url: "https://cdn.example.com/prefix" }, + }); +}); + +test("media page hides the banner and offers public links once configured", async ({ page }) => { + await mockMedia(page, { configured: true }); + await page.goto("/dashboard/media"); + await expect(page.getByRole("alert")).toHaveCount(0); + await page + .getByRole("button", { name: /hunyuan-image-v3\.0/ }) + .first() + .click(); + const link = page.getByRole("dialog").getByRole("button", { name: "复制链接" }); + await expect(link).toBeVisible(); +}); + +test("media settings stay editable only when not locked by startup", async ({ page }) => { + await mockMedia(page, { locked: true }); + await page.goto("/dashboard/media"); + await expect(page.getByRole("alert")).toContainText("由启动配置锁定"); + await page.getByRole("button", { name: "媒体设置" }).click(); + const field = page.getByRole("dialog").getByLabel("资源访问地址"); + await expect(field).toBeDisabled(); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 3b99228..04f47db 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { Dashboard } from "./pages/Dashboard"; import { Models } from "./pages/Models"; import { Credentials } from "./pages/Credentials"; import { Logs } from "./pages/Logs"; +import { Media } from "./pages/Media"; import { Settings } from "./pages/Settings"; import { Storage } from "./pages/Storage"; import { Updates } from "./pages/Updates"; @@ -18,6 +19,7 @@ const navigation = [ ["/models", "nav.models", "model"], ["/credentials", "nav.credentials", "key"], ["/logs", "nav.logs", "logs"], + ["/media", "nav.media", "image"], ["/storage", "nav.storage", "database"], ["/settings", "nav.settings", "settings"], ["/updates", "nav.updates", "up"], @@ -245,6 +247,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components.tsx b/web/src/components.tsx index 1b0295d..f412aff 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -42,6 +42,8 @@ export function Icon({ name = "grid" }: { name?: string }) { "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3Zm8 3v12c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6c0 1.7-3.6 3-8 3s-8-1.3-8-3", database: "M12 8c4.4 0 8-1.3 8-3s-3.6-3-8-3-8 1.3-8 3 3.6 3 8 3Zm-8 2v9c0 1.7 3.6 3 8 3s8-1.3 8-3v-9M4 14.5c0 1.7 3.6 3 8 3s8-1.3 8-3", + image: "m12 3 9 4.5-9 4.5-9-4.5 9-4.5Zm-9 9 9 4.5 9-4.5M3 16l9 4.5 9-4.5", + play: "M8 5.5v13l11-6.5-11-6.5Z", }; return ( ; export type MessageKey = keyof typeof zh; @@ -936,6 +1017,93 @@ const en: Record = { "logs.col.modelProfile": "Model / product", "logs.logTypes": "Log types", "logs.pageIncomplete": "Log page response incomplete", + "nav.media": "Media", + "val.protocol.images": "Images", + "val.protocol.videos": "Videos", + "logs.action.media_asset_deleted": "Media asset deleted", + "logs.action.media_task_refreshed": "Video task refreshed", + "media.title": "Media generation", + "media.desc": "Generated images and videos: assets, tasks and model availability.", + "media.refresh": "Refresh", + "media.settings": "Media settings", + "media.baseConfigured": "Asset base URL configured", + "media.baseMissing": "Asset base URL missing", + "media.banner": + "No asset base URL configured: images return b64 only and saved videos expose no link.", + "media.bannerAction": "Configure", + "media.bannerLocked": + "The asset base URL is locked by startup configuration: images return b64 only and videos expose no link.", + "media.kpi.usage": "Local usage", + "media.kpi.images": "Images", + "media.kpi.videos": "Videos", + "media.kpi.tasks": "Video tasks", + "media.kpi.tasksValue": "{active} active · {failed} failed", + "media.retention": "Retained {hours} h", + "media.tab.library": "Library", + "media.tab.tasks": "Video tasks", + "media.tab.models": "Models", + "media.filter.all": "All", + "media.filter.images": "Images", + "media.filter.videos": "Videos", + "media.filter.active": "Active", + "media.filter.completed": "Completed", + "media.filter.failed": "Failed", + "media.empty.library": "No media assets yet", + "media.empty.libraryHint": + "Without an asset base URL images are not stored; only videos are saved locally.", + "media.empty.tasks": "No video tasks", + "media.remaining": "{time} left", + "media.preview": "Asset details", + "media.copyLink": "Copy link", + "media.copied": "Link copied", + "media.download": "Download", + "media.delete": "Delete", + "media.deleteConfirm": "Confirm delete", + "media.deleteHint": "This cannot be undone; bound tasks lose their asset link.", + "media.noLinkNote": + "Configure an asset base URL to copy public links; preview and download here always work.", + "media.tasks.taskId": "Task ID", + "media.tasks.model": "Model", + "media.tasks.account": "Account", + "media.tasks.credit": "Credits", + "media.tasks.asset": "Asset", + "media.tasks.refresh": "Refresh status", + "media.tasks.view": "View", + "media.status.queued": "Queued", + "media.status.in_progress": "Generating", + "media.status.completed": "Completed", + "media.status.failed": "Failed", + "media.kind.image": "Image", + "media.kind.video": "Video", + "media.models.model": "Model ID", + "media.models.kind": "Kind", + "media.models.source": "Source", + "media.models.accounts": "Accounts", + "media.models.default": "Default", + "media.models.noAccounts": "No account", + "media.models.kindImage": "Text to image", + "media.models.kindEdit": "Image edit", + "media.models.kindVideo": "Video", + "media.models.sourceStatic": "Static list", + "media.models.sourceCatalog": "Account catalog", + "media.models.note": + "Account counts follow the media routing rules (profile whitelist, catalog, balance); authorization refusals stay with the upstream.", + "media.settings.title": "Media settings", + "media.settings.baseUrl": "Asset base URL", + "media.settings.baseUrlPlaceholder": "https://media.example.com", + "media.settings.baseUrlHelp": + "Must start with http:// or https://; when empty images return b64 only and videos expose no link.", + "media.settings.baseUrlInvalid": + "The URL must start with http:// or https:// and carry no userinfo or query", + "media.settings.baseUrlExample": "Example: {url}", + "media.settings.limit": "Capacity limit (MB)", + "media.settings.ttl": "Retention", + "media.settings.ttlHours": "{hours} hours", + "media.settings.locked": "Locked by startup configuration", + "media.settings.save": "Save", + "media.settings.saved": "Media settings saved", + "media.settings.note": + "Shrinking capacity or retention evicts older assets on the next write or cleanup.", }; const messages: Record> = { zh, en }; diff --git a/web/src/media.test.tsx b/web/src/media.test.tsx new file mode 100644 index 0000000..d404abf --- /dev/null +++ b/web/src/media.test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, useResource } from "./api"; +import { + Media, + normalizeMediaSummary, + remainingLabel, + taskStatusTone, + validateMediaBaseUrl, +} from "./pages/Media"; + +vi.mock("./api", async (original) => ({ + ...(await original()), + useResource: vi.fn(), +})); + +const summary = normalizeMediaSummary({ + configured: false, + base_url: "", + locked: false, + available: true, + limit_bytes: 1073741824, + ttl_hours: 72, + usage: { + total_bytes: 327155712, + images: { count: 48, bytes: 100663296 }, + videos: { count: 6, bytes: 226492416 }, + }, + tasks: { active: 2, failed: 1 }, + models: [ + { + id: "hunyuan-image-v3.0", + kinds: ["image"], + sources: ["static"], + default: ["image"], + accounts: 1, + }, + { + id: "minimax-video-h3", + kinds: ["video"], + sources: ["static"], + default: ["video"], + accounts: 0, + }, + ], +}); + +const assets = { + items: [ + { + id: "img_" + "a".repeat(32), + kind: "image", + mime: "image/png", + size: 4096, + sha256: "0".repeat(64), + model: "hunyuan-image-v3.0", + created: 1789000000, + expires: 1789000000 + 71 * 3600, + public_url: "https://media.example.com/v1/media/images/img_" + "a".repeat(32), + }, + { + id: "vid_" + "b".repeat(32), + kind: "video", + mime: "video/mp4", + size: 8192, + sha256: "1".repeat(64), + model: "minimax-video-h3", + created: 1789000000, + expires: 1789000000 + 180 * 60, + }, + ], + has_more: false, + next_cursor: null, +}; + +function mockResources(overrides: { summary?: object } = {}) { + vi.mocked(useResource).mockImplementation(((path: string) => { + if (path === "/media") + return { + data: { ...summary, ...overrides.summary }, + loading: false, + refreshing: false, + error: null, + reload: vi.fn(), + }; + if (path.startsWith("/media/assets")) + return { data: assets, loading: false, refreshing: false, error: null, reload: vi.fn() }; + return { + data: { items: [], has_more: false, next_cursor: null }, + loading: false, + refreshing: false, + error: null, + reload: vi.fn(), + }; + }) as unknown as typeof useResource); +} + +beforeEach(() => { + vi.restoreAllMocks(); + mockResources(); +}); + +describe("media summary normalization", () => { + it("maps usage, tasks and models with safe defaults", () => { + expect(summary.usage.images.count).toBe(48); + expect(summary.models[0]).toEqual({ + id: "hunyuan-image-v3.0", + kinds: ["image"], + sources: ["static"], + defaults: ["image"], + accounts: 1, + }); + const empty = normalizeMediaSummary({}); + expect(empty.usage.total_bytes).toBe(0); + expect(empty.models).toEqual([]); + expect(empty.ttl_hours).toBe(72); + }); +}); + +describe("asset base url validation", () => { + it("mirrors the gateway rules", () => { + expect(validateMediaBaseUrl("")).toBeNull(); + expect(validateMediaBaseUrl(" ")).toBeNull(); + expect(validateMediaBaseUrl("https://media.example.com")).toBeNull(); + expect(validateMediaBaseUrl("http://10.0.0.1:8787/base")).toBeNull(); + expect(validateMediaBaseUrl("example.com")).toBe("media.settings.baseUrlInvalid"); + expect(validateMediaBaseUrl("ftp://example.com")).toBe("media.settings.baseUrlInvalid"); + expect(validateMediaBaseUrl("https://user:pw@example.com")).toBe( + "media.settings.baseUrlInvalid", + ); + expect(validateMediaBaseUrl("https://example.com?a=1")).toBe("media.settings.baseUrlInvalid"); + }); +}); + +describe("retention and status mapping", () => { + it("labels remaining time and warns under six hours", () => { + const now = Date.now(); + expect(remainingLabel(null)).toEqual({ text: "—", warn: false }); + expect(remainingLabel(now / 1000 + 90 * 3600, now)).toEqual({ text: "4 天", warn: false }); + expect(remainingLabel(now / 1000 + 3 * 3600, now)).toEqual({ text: "3.0 小时", warn: true }); + expect(remainingLabel(now / 1000 + 20 * 60, now)).toEqual({ text: "20 分钟", warn: true }); + }); + + it("maps task statuses to badge tones", () => { + expect(taskStatusTone("completed")).toBe("good"); + expect(taskStatusTone("failed")).toBe("bad"); + expect(taskStatusTone("queued")).toBe("neutral"); + expect(taskStatusTone("in_progress")).toBe("neutral"); + expect(taskStatusTone(undefined)).toBe("neutral"); + }); +}); + +describe("media page", () => { + it("shows the unconfigured banner with a configure action", () => { + render(); + expect(screen.getByRole("alert").textContent).toContain("未配置资源访问地址"); + expect(screen.getByRole("button", { name: "去配置" })).toBeTruthy(); + }); + + it("shows the locked banner without an action when locked by startup", () => { + mockResources({ summary: { locked: true } }); + render(); + expect(screen.getByRole("alert").textContent).toContain("由启动配置锁定"); + expect(screen.queryByRole("button", { name: "去配置" })).toBeNull(); + }); + + it("renders asset cards and hides copy-link until a base URL exists", () => { + render(); + fireEvent.click(screen.getAllByRole("button", { name: /hunyuan-image-v3\.0/ })[0]!); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "复制链接" })).toBeNull(); + expect(screen.getByRole("link", { name: "下载" })).toHaveProperty( + "href", + expect.stringContaining("/admin/media/assets/img_"), + ); + }); + + it("offers copy-link once the base URL is configured", () => { + mockResources({ summary: { configured: true, base_url: "https://media.example.com" } }); + render(); + expect(screen.queryByRole("alert")).toBeNull(); + fireEvent.click(screen.getAllByRole("button", { name: /hunyuan-image-v3\.0/ })[0]!); + expect(screen.getByRole("button", { name: "复制链接" })).toBeTruthy(); + }); + + it("requires a second click before deleting an asset", async () => { + const remove = vi.spyOn(api, "delete").mockResolvedValue({ data: {} } as never); + render(); + fireEvent.click(screen.getAllByRole("button", { name: /hunyuan-image-v3\.0/ })[0]!); + const danger = () => screen.getAllByRole("button", { name: /删除/ })[0]!; + fireEvent.click(danger()); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "确认删除" })); + await waitFor(() => expect(remove).toHaveBeenCalled()); + expect(remove.mock.calls[0]![0]).toContain("/media/assets/img_"); + }); +}); diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 32733c9..7847687 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -345,6 +345,18 @@ export function Logs() { ? t(eventKindLabel(item.kind)!) : item.kind, )} + {kind === "request" && + (item.protocol === "images" || item.protocol === "videos") && ( + + + {t( + item.protocol === "images" + ? "val.protocol.images" + : "val.protocol.videos", + )} + + + )} {tools && ( diff --git a/web/src/pages/Media.tsx b/web/src/pages/Media.tsx new file mode 100644 index 0000000..1978d4b --- /dev/null +++ b/web/src/pages/Media.tsx @@ -0,0 +1,823 @@ +import { useEffect, useState } from "react"; +import { + api, + errorMessage, + list, + number, + object, + text, + useResource, + type RecordValue, +} from "../api"; +import { + Badge, + Drawer, + Empty, + ErrorNotice, + Fields, + Icon, + PageTitle, + RefreshIcon, + ResourceState, + SegmentThumb, +} from "../components"; +import { bytes } from "../values"; +import { notify } from "../toast"; +import { Select } from "../fields"; +import { useT } from "../i18n"; +import s from "../ui.module.scss"; + +const TTL_PRESETS = [24, 72, 168, 720]; + +/** Client-side mirror of the gateway's media_public_base_url validator. */ +export function validateMediaBaseUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) + return "media.settings.baseUrlInvalid"; + if (parsed.username || parsed.password || parsed.search || parsed.hash) + return "media.settings.baseUrlInvalid"; + return null; + } catch { + return "media.settings.baseUrlInvalid"; + } +} + +export type MediaModel = { + id: string; + kinds: string[]; + sources: string[]; + defaults: string[]; + accounts: number; +}; + +export type MediaSummary = { + configured: boolean; + base_url: string; + locked: boolean; + available: boolean; + limit_bytes: number; + ttl_hours: number; + usage: { + total_bytes: number; + images: { count: number; bytes: number }; + videos: { count: number; bytes: number }; + }; + tasks: { active: number; failed: number }; + models: MediaModel[]; +}; + +export function normalizeMediaSummary(value: unknown): MediaSummary { + const data = object(value, "媒体概览"); + const group = (raw: unknown) => ({ + count: number((raw as RecordValue)?.count) ?? 0, + bytes: number((raw as RecordValue)?.bytes) ?? 0, + }); + return { + configured: data.configured === true, + base_url: typeof data.base_url === "string" ? data.base_url : "", + locked: data.locked === true, + available: data.available !== false, + limit_bytes: number(data.limit_bytes) ?? 0, + ttl_hours: number(data.ttl_hours) ?? 72, + usage: { + total_bytes: number((data.usage as RecordValue)?.total_bytes) ?? 0, + images: group((data.usage as RecordValue)?.images), + videos: group((data.usage as RecordValue)?.videos), + }, + tasks: { + active: number((data.tasks as RecordValue)?.active) ?? 0, + failed: number((data.tasks as RecordValue)?.failed) ?? 0, + }, + models: Array.isArray(data.models) + ? list(data.models, "媒体模型").map((item) => ({ + id: text(item.id), + kinds: Array.isArray(item.kinds) ? item.kinds.map(text) : [], + sources: Array.isArray(item.sources) ? item.sources.map(text) : [], + defaults: Array.isArray(item.default) ? item.default.map(text) : [], + accounts: number(item.accounts) ?? 0, + })) + : [], + }; +} + +/** Remaining retention as a short label; under six hours it turns warning-coloured. */ +export function remainingLabel( + expires: unknown, + now = Date.now(), +): { text: string; warn: boolean } { + const end = number(expires); + if (end === null) return { text: "—", warn: false }; + const seconds = Math.max(0, end - now / 1000); + const hours = seconds / 3600; + if (hours >= 48) return { text: `${Math.round(hours / 24)} 天`, warn: false }; + if (hours >= 1) return { text: `${hours.toFixed(1)} 小时`, warn: hours < 6 }; + return { text: `${Math.max(1, Math.round(seconds / 60))} 分钟`, warn: true }; +} + +export function taskStatusTone(status: unknown): "good" | "bad" | "neutral" { + if (status === "completed") return "good"; + if (status === "failed") return "bad"; + return "neutral"; +} + +function pageNormalize(value: unknown) { + const data = object(value, "媒体列表"); + if (typeof data.has_more !== "boolean" || (data.has_more && typeof data.next_cursor !== "string")) + throw new Error("媒体列表分页响应不完整"); + return { + items: list(data.items), + next_cursor: typeof data.next_cursor === "string" ? data.next_cursor : null, + has_more: data.has_more, + }; +} + +function AssetPreview({ + item, + configured, + onClose, + onDeleted, +}: { + item: RecordValue; + configured: boolean; + onClose: () => void; + onDeleted: () => void; +}) { + const t = useT(); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const assetId = text(item.id); + const content = `/admin/media/assets/${encodeURIComponent(assetId)}/content`; + const isVideo = item.kind === "video"; + const link = typeof item.public_url === "string" ? item.public_url : ""; + return ( + + +
+ {isVideo ? ( + + ) : ( + {text(item.model) + )} +
+ +
+ {configured && link && ( + + )} + + {t("media.download")} + + {confirming ? ( + + ) : ( + + )} +
+ {!configured &&

{t("media.noLinkNote")}

} + {confirming &&

{t("media.deleteHint")}

} +
+ ); +} + +function MediaSettingsDrawer({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) { + const t = useT(); + const [form, setForm] = useState<{ + revision: number; + base: string; + limit: number; + ttl: number; + locked: Set; + } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let cancelled = false; + void api + .get("/settings") + .then((response) => { + if (cancelled) return; + const data = object(response.data, "设置"); + const items = list(data.items); + const find = (key: string) => items.find((item) => item.key === key); + setForm({ + revision: number(data.revision) ?? 0, + base: text(find("media_public_base_url")?.value ?? ""), + limit: number(find("media_max_total_mb")?.value) ?? 1024, + ttl: number(find("media_ttl_hours")?.value) ?? 72, + locked: new Set( + items + .filter((item) => item.locked === true && typeof item.key === "string") + .map((item) => text(item.key)), + ), + }); + }) + .catch((err: unknown) => { + if (!cancelled) setError(errorMessage(err)); + }); + return () => { + cancelled = true; + }; + }, []); + const invalid = form ? validateMediaBaseUrl(form.base) : null; + const presets = + form && !TTL_PRESETS.includes(form.ttl) + ? [...TTL_PRESETS, form.ttl].sort((a, b) => a - b) + : TTL_PRESETS; + const save = () => { + if (!form || invalid) return; + const values: Record = {}; + if (!form.locked.has("media_public_base_url")) + values.media_public_base_url = form.base.trim().replace(/\/+$/, ""); + if (!form.locked.has("media_max_total_mb")) values.media_max_total_mb = form.limit; + if (!form.locked.has("media_ttl_hours")) values.media_ttl_hours = form.ttl; + setBusy(true); + setError(null); + void api + .patch("/settings", { revision: form.revision, values }) + .then(() => { + notify({ key: "media-settings", tone: "good", title: t("media.settings.saved") }); + onSaved(); + onClose(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; + const lockedNote = (key: string) => + form?.locked.has(key) ?

{t("media.settings.locked")}

: null; + return ( + + + {form && ( +
{ + event.preventDefault(); + save(); + }} + > + + {invalid && ( + + )} +

{t("media.settings.baseUrlHelp")}

+ {!invalid && form.base.trim() && ( +

+ {t("media.settings.baseUrlExample", { + url: `${form.base.trim().replace(/\/+$/, "")}/v1/media/images/img_…`, + })} +

+ )} + {lockedNote("media_public_base_url")} + + {lockedNote("media_max_total_mb")} + + {lockedNote("media_ttl_hours")} +

{t("media.settings.note")}

+ +
+ )} +
+ ); +} + +export function Media() { + const t = useT(); + const [tab, setTab] = useState<"library" | "tasks" | "models">("library"); + const [kind, setKind] = useState(""); + const [taskStatus, setTaskStatus] = useState(""); + const [assetCursors, setAssetCursors] = useState([]); + const [taskCursors, setTaskCursors] = useState([]); + const [preview, setPreview] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const summary = useResource("/media", normalizeMediaSummary); + const assetParams = new URLSearchParams({ limit: "24" }); + if (kind) assetParams.set("kind", kind); + if (assetCursors.length) assetParams.set("cursor", assetCursors.at(-1)!); + const assets = useResource(`/media/assets?${assetParams.toString()}`, pageNormalize); + const taskParams = new URLSearchParams({ limit: "50" }); + if (taskStatus) taskParams.set("status", taskStatus); + if (taskCursors.length) taskParams.set("cursor", taskCursors.at(-1)!); + const tasks = useResource(`/media/tasks?${taskParams.toString()}`, pageNormalize); + const data = summary.data; + const configured = data?.configured === true; + const reloadAll = () => { + summary.reload(); + if (tab === "library") assets.reload(); + if (tab === "tasks") tasks.reload(); + }; + + const openAsset = (item: RecordValue) => setPreview(item); + const openAssetById = (id: string) => { + void api + .get(`/media/assets/${encodeURIComponent(id)}`) + .then((response) => setPreview(object(response.data, "媒体资源"))) + .catch((err: unknown) => + notify({ key: `media-open-${id}`, tone: "bad", title: errorMessage(err) }), + ); + }; + + return ( + <> + + + {configured ? t("media.baseConfigured") : t("media.baseMissing")} + + + + + } + /> + {data && !configured && ( +
+ +

{data.locked ? t("media.bannerLocked") : t("media.banner")}

+ {!data.locked && ( + + )} +
+ )} + {data && ( +
+
+ {t("media.kpi.usage")} + + {bytes(data.usage.total_bytes)} / {bytes(data.limit_bytes)} + +
= data.limit_bytes ? s.full : data.usage.total_bytes >= data.limit_bytes * 0.85 ? s.warn : ""}`} + > + +
+
+
+ {t("media.kpi.images")} + + {data.usage.images.count} · {bytes(data.usage.images.bytes)} + +
+ {t("media.retention", { hours: data.ttl_hours })} +
+
+
+ {t("media.kpi.videos")} + + {data.usage.videos.count} · {bytes(data.usage.videos.bytes)} + +
+
+ {t("media.kpi.tasks")} + + {t("media.kpi.tasksValue", { active: data.tasks.active, failed: data.tasks.failed })} + +
+
+ )} +
+ + {( + [ + ["library", t("media.tab.library")], + ["tasks", t("media.tab.tasks")], + ["models", t("media.tab.models")], + ] as const + ).map(([value, label]) => ( + + ))} +
+ + {tab === "library" && ( + <> +
+ +
+ + {assets.data && + (assets.data.items.length ? ( +
+ {assets.data.items.map((item) => { + const remaining = remainingLabel(item.expires); + const isVideo = item.kind === "video"; + return ( + + ); + })} +
+ ) : ( + {t("media.empty.libraryHint")} + ))} +
+ 第 {assetCursors.length + 1} 页 +
+ + +
+
+ + )} + + {tab === "tasks" && ( + <> +
+ +
+ + {tasks.data && + (tasks.data.items.length ? ( +
+ + + + + + + + + + + + + + + {tasks.data.items.map((item) => { + const status = text(item.status); + const active = status === "queued" || status === "in_progress"; + return ( + + + + + + + + + + + ); + })} + +
{t("logs.col.time")}{t("media.tasks.taskId")}{t("media.tasks.model")}{t("media.tasks.account")}{t("logs.col.status")}{t("media.tasks.credit")}{t("media.tasks.asset")}{t("logs.col.actions")}
+ {typeof item.created === "number" + ? new Date(item.created * 1000).toLocaleString("zh-CN") + : text(item.created)} + + {text(item.task_id).length > 18 + ? `${text(item.task_id).slice(0, 18)}…` + : text(item.task_id)} + {text(item.model)} + {text(item.profile)} + {text(item.account_key).slice(0, 12)} + + + {t( + status === "queued" + ? "media.status.queued" + : status === "in_progress" + ? "media.status.in_progress" + : status === "completed" + ? "media.status.completed" + : "media.status.failed", + )} + + {item.error ? ( + + {text(item.error).length > 40 + ? `${text(item.error).slice(0, 40)}…` + : text(item.error)} + + ) : null} + {number(item.credit) === null ? "—" : number(item.credit)} + {item.media_id ? ( + + ) : ( + — + )} + + {active && ( + + )} +
+
+ ) : ( + + ))} +
+ 第 {taskCursors.length + 1} 页 +
+ + +
+
+ + )} + + {tab === "models" && data && ( + <> +
+ + + + + + + + + + + {data.models.map((model) => ( + + + + + + + ))} + +
{t("media.models.model")}{t("media.models.kind")}{t("media.models.source")}{t("media.models.accounts")}
+ {model.id} + {model.defaults.length > 0 && ( + + {t("media.models.default")} ·{" "} + {model.defaults + .map((kindName) => + t( + kindName === "image" + ? "media.models.kindImage" + : kindName === "edit" + ? "media.models.kindEdit" + : "media.models.kindVideo", + ), + ) + .join("/")} + + )} + + {model.kinds + .map((kindName) => + t( + kindName === "image" + ? "media.models.kindImage" + : kindName === "edit" + ? "media.models.kindEdit" + : "media.models.kindVideo", + ), + ) + .join(" / ")} + + {model.sources + .map((source) => + t( + source === "catalog" + ? "media.models.sourceCatalog" + : "media.models.sourceStatic", + ), + ) + .join(" + ")} + + {model.accounts > 0 ? ( + model.accounts + ) : ( + {t("media.models.noAccounts")} + )} +
+
+

{t("media.models.note")}

+ + )} + + {preview && ( + setPreview(null)} + onDeleted={() => { + assets.reload(); + summary.reload(); + }} + /> + )} + {settingsOpen && ( + setSettingsOpen(false)} + onSaved={() => { + summary.reload(); + assets.reload(); + }} + /> + )} + + ); +} diff --git a/web/src/ui.module.scss b/web/src/ui.module.scss index 03293dc..76556d5 100644 --- a/web/src/ui.module.scss +++ b/web/src/ui.module.scss @@ -4042,3 +4042,115 @@ $white: var(--page); transform: translateY(-10px) scale(0.97); } } + +/* Media library: a responsive card grid over the shared glass system. */ +.mediaGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + gap: 14px; +} +.mediaCard { + @include glass-well; + display: block; + width: 100%; + padding: 0; + border-radius: 16px; + overflow: hidden; + text-align: start; + cursor: pointer; + transition: + transform 0.16s ease, + box-shadow 0.16s ease; +} +.mediaCard:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} +.mediaCard:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.mediaThumb { + display: grid; + place-items: center; + aspect-ratio: 1; + background: var(--soft); + color: var(--muted); +} +.mediaThumb.isVideo { + aspect-ratio: 16 / 9; +} +.mediaThumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.mediaMeta { + padding: 10px 12px; + display: grid; + gap: 3px; +} +.mediaMeta small { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.mediaMeta strong { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.usageMeter { + height: 6px; + border-radius: 999px; + background: var(--soft); + overflow: hidden; +} +.usageMeter > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width 0.25s ease; +} +.usageMeter.warn > span { + background: var(--warning); +} +.usageMeter.full > span { + background: var(--error); +} +.mediaPreview { + display: block; + max-width: 100%; + max-height: 60vh; + margin-inline: auto; + object-fit: contain; + border-radius: 14px; + background: var(--card); +} +@media (max-width: 600px) { + .mediaGrid { + grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); + } +} + +/* Media page helpers. */ +.mediaPreviewWrap { + display: grid; + place-items: center; + margin-bottom: 14px; +} +.errorText { + color: var(--error); + font-size: 13px; + margin: -6px 0 10px; +} +.warnText { + color: var(--warning); +} +@media (prefers-reduced-motion: reduce) { + .mediaCard:hover { + transform: none; + } +} diff --git a/web/src/values.tsx b/web/src/values.tsx index 43b4151..189f9b0 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -82,6 +82,10 @@ const labels: Record = { fetched_at: "同步时间", updated_at: "更新时间", created_at: "创建时间", + created: "创建时间", + expires: "过期时间", + mime: "MIME 类型", + sha256: "SHA-256", generated_at: "生成时间", last_failure_at: "最近失败", token_expires_at: "令牌到期", @@ -313,6 +317,8 @@ const eventActions: Record = { "models.rate_probe": "logs.action.models_rate_probe", "models.scan": "logs.action.models_scan", trial: "logs.action.trial", + "media.asset_deleted": "logs.action.media_asset_deleted", + "media.task_refreshed": "logs.action.media_task_refreshed", "credit.income": "logs.action.credit_income", "credit.consume": "logs.action.credit_consume", "credit.expire": "logs.action.credit_expire", From 4cb7ecfbc2984ea8739d108cfe87832fe31ee920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:01:12 +0800 Subject: [PATCH 2/6] Fix media routing scope, CLI request shapes and store concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contract layer mirrors the CLI ImageService body: three-family branches, default 1024x1024 size, exact catalog tags, multiline prompts (32000-char shared bound), business-error envelope checks (MediaUpstreamError → 502 with upstream code, never replayed) - Media catalog scope reads the account root table (serves); CatalogIndex builds media_kinds for O(1) chat-guard and model-table lookups; preview moves media models to the root list with real tags - Store: payload staged in media/.incoming outside the lock, TTL derived from created + current setting (expires column dropped, schema v1), explicit task-error clearing; admin media routes answer 503 when the store is missing and serialize created_at/expires_at/updated_at - Gateway flow: image validation 400s, per-task refresh locks (one download), retryable vs deterministic download failures, concurrent multi-image downloads with partial-failure results and no orphans, store pre-check before billed upstream rounds, expired reads 404 - WebUI: created_at/expires_at/updated_at fields, catalog source labels, picture-style icon, i18n copy-failure text, warn hint on in-flight tasks with download retries; docs updated in pairs --- app/admin_api.py | 76 +++++--- app/catalog_index.py | 11 +- app/media_generation.py | 97 +++++++--- app/media_store.py | 184 ++++++++++++------ app/settings.py | 1 + converter.py | 296 +++++++++++++++++++--------- docs/clients.md | 3 +- docs/clients.zh-CN.md | 3 +- docs/webui.md | 2 +- docs/webui.zh-CN.md | 2 +- preview/seed.py | 3 +- preview/upstream.py | 9 +- tests/test_media_generation.py | 339 +++++++++++++++++++++++++++++---- web/e2e/media.spec.ts | 4 +- web/src/components.tsx | 3 +- web/src/i18n.tsx | 2 + web/src/media.test.tsx | 49 ++++- web/src/pages/Media.tsx | 8 +- web/src/values.tsx | 2 - 19 files changed, 827 insertions(+), 267 deletions(-) diff --git a/app/admin_api.py b/app/admin_api.py index 8eadfe6..eb9fbb3 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -745,12 +745,30 @@ async def logs_clear(request): return error_response(503, "日志清理未完成,请检查审计存储状态") return JSONResponse(result) - def media_store_or_none(): + def media_store(): store = config.get("media_store") if store is None: - raise ValueError("媒体存储不可用,请检查数据目录权限或重启网关") + raise _MediaUnavailable() return store + class _MediaUnavailable(Exception): + pass + + def media_route(method, path): + """Register one media handler; a missing store answers 503, never 400 or 500.""" + def decorate(function): + async def guarded(request: Request): + try: + return await function(request) + except _MediaUnavailable: + return error_response(503, "媒体存储不可用,请检查数据目录权限或重启网关") + except ValueError: + return error_response(400, "媒体请求参数无效,请检查 kind/status/limit/cursor") + guarded.__name__ = function.__name__ + app.add_api_route(path, guarded, methods=[method]) + return function + return decorate + def media_page_params(request, *, status=False): params = request.query_params try: @@ -789,40 +807,42 @@ def build(): }) return await run_in_threadpool(build) - @route("GET", "/admin/media/assets") + def media_asset_row(item, base): + entry = dict(item) + entry["created_at"] = item["created"] + entry["expires_at"] = item["created"] + float(config.get("media_ttl_hours", 72) or 72) * 3600 + if base: + entry["public_url"] = f"{base}/v1/media/{'videos' if item['kind'] == 'video' else 'images'}/{item['id']}" + return entry + + @media_route("GET", "/admin/media/assets") async def media_assets(request): kind, cursor, limit = media_page_params(request) def build(): - page = media_store_or_none().list_assets(kind, cursor, limit) + page = media_store().list_assets(kind, cursor, limit) base = str(config.get("media_public_base_url") or "") - items = [] - for item in page["items"]: - entry = dict(item) - if base: - entry["public_url"] = f"{base}/v1/media/{'videos' if item['kind'] == 'video' else 'images'}/{item['id']}" - items.append(entry) + items = [media_asset_row(item, base) for item in page["items"]] return JSONResponse({**page, "items": items}) return await run_in_threadpool(build) - @route("GET", "/admin/media/assets/{asset_id}") + @media_route("GET", "/admin/media/assets/{asset_id}") async def media_asset_meta(request): def build(): - meta = media_store_or_none().get_asset(request.path_params["asset_id"]) + meta = media_store().get_asset(request.path_params["asset_id"]) if meta is None: return error_response(404, "媒体资源不存在或已清理") - base = str(config.get("media_public_base_url") or "") - if base: - meta = {**meta, - "public_url": f"{base}/v1/media/{'videos' if meta['kind'] == 'video' else 'images'}/{meta['id']}"} - return JSONResponse(meta) + return JSONResponse(media_asset_row(meta, str(config.get("media_public_base_url") or ""))) return await run_in_threadpool(build) - @route("GET", "/admin/media/assets/{asset_id}/content") + @media_route("GET", "/admin/media/assets/{asset_id}/content") async def media_asset_content(request): def build(): - store = media_store_or_none() + store = media_store() meta = store.get_asset(request.path_params["asset_id"]) + if meta is not None and media_store_module.is_expired( + meta, float(config.get("media_ttl_hours", 72) or 72)): + meta = None # Expired assets disappear now, not at the next hourly sweep. file = store.asset_file(meta) if meta else None if file is None: return error_response(404, "媒体资源不存在或已清理") @@ -832,25 +852,28 @@ def build(): return FileResponse(file, media_type=meta["mime"], headers=headers) return await run_in_threadpool(build) - @route("DELETE", "/admin/media/assets/{asset_id}") + @media_route("DELETE", "/admin/media/assets/{asset_id}") async def media_asset_delete(request): def remove(): asset_id = request.path_params["asset_id"] - if not media_store_or_none().delete_asset(asset_id): + if not media_store().delete_asset(asset_id): return error_response(404, "媒体资源不存在或已清理") event("media.asset_deleted", {"asset": asset_id}) return JSONResponse({"deleted": asset_id}) return await run_in_threadpool(remove) - @route("GET", "/admin/media/tasks") + @media_route("GET", "/admin/media/tasks") async def media_tasks(request): status, cursor, limit = media_page_params(request, status=True) def build(): - return JSONResponse(media_store_or_none().list_tasks(status, cursor, limit)) + page = media_store().list_tasks(status, cursor, limit) + items = [{**item, "created_at": item["created"], "updated_at": item["updated"]} + for item in page["items"]] + return JSONResponse({**page, "items": items}) return await run_in_threadpool(build) - @route("POST", "/admin/media/tasks/{task_id}/refresh") + @media_route("POST", "/admin/media/tasks/{task_id}/refresh") async def media_task_refresh(request): task_id = request.path_params["task_id"] try: @@ -859,7 +882,10 @@ async def media_task_refresh(request): detail = exc.detail.get("error", {}).get("message") if isinstance(exc.detail, dict) else None return error_response(exc.status_code, detail or "视频任务不存在或无法刷新") event("media.task_refreshed", {"task": task_id}) - return JSONResponse(gateway._media_video_response(task)) + meta = None + if task["status"] == "completed" and task["media_id"]: + meta = await run_in_threadpool(media_store().get_asset, task["media_id"]) + return JSONResponse(gateway._media_video_response(task, meta)) @route("GET", "/admin/storage") async def storage_get(request): diff --git a/app/catalog_index.py b/app/catalog_index.py index 3c8486e..65d1576 100644 --- a/app/catalog_index.py +++ b/app/catalog_index.py @@ -1,9 +1,8 @@ """Read-only indexes over published account catalogs, built once per catalog revision.""" from __future__ import annotations - +from .media_generation import catalog_kinds from .model_catalog_view import INTERNATIONAL, share_models - def account_scope(account, scope="models"): """Merge root candidates into selector models while retaining selector metadata.""" picker = account.get("models") @@ -31,6 +30,7 @@ def __init__(self, accounts, *, revision=0, policy_revision=0, is_enabled=None, self.policy_revision = policy_revision self._enabled = is_enabled or (lambda _identity: True) self._account: dict[tuple[int, str], list[dict] | None] = {} + self.media_kinds: dict[str, tuple] = {} # model id → media kinds from serves tags. self._profile: dict[str, dict[str, tuple[dict, ...]]] = {} self.profiles: tuple[str, ...] = tuple(dict.fromkeys( account.get("profile") for account in self.accounts.values() if account.get("profile"))) @@ -44,6 +44,13 @@ def __init__(self, accounts, *, revision=0, policy_revision=0, is_enabled=None, items = self._effective(account, scope, sources) if items is None: continue + if scope == "serves": + # Media membership comes from the root table; keep it O(1) per model. + for item in items: + kinds = catalog_kinds(item) + if kinds and isinstance(item.get("id"), str): + known = self.media_kinds.setdefault(item["id"], ()) + self.media_kinds[item["id"]] = tuple(dict.fromkeys(known + kinds)) merged.setdefault(profile, []).extend(items) self._profile[scope] = {name: tuple(items) for name, items in merged.items()} diff --git a/app/media_generation.py b/app/media_generation.py index e0ceac9..91ba5fc 100644 --- a/app/media_generation.py +++ b/app/media_generation.py @@ -24,9 +24,9 @@ DEFAULT_IMAGE_EDIT_MODEL = "hunyuan-image-v2.0-general-edit" DEFAULT_VIDEO_MODEL = "minimax-video-h3" -GEMINI_IMAGE_MODELS = frozenset(("gemini-3.0-pro-image", "gemini-3.1-flash-image", "gemini-2.5-flash-image")) - MAX_IMAGE_ITEMS = 4 # Public n bound; the upstream contract allows 1..4. +MAX_PROMPT_CHARS = 32000 # Shared by image and video prompts and image references. +DEFAULT_IMAGE_SIZE = "1024x1024" VIDEO_RESOLUTIONS = ("768P", "2K") MAX_VIDEO_SECONDS = 60 @@ -34,11 +34,24 @@ VIDEO_STATUS_MAP = {"processing": "in_progress", "completed": "completed", "failed": "failed"} PUBLIC_VIDEO_STATUSES = ("queued", "in_progress", "completed", "failed") +# Exact catalog tag → media kinds; tags never match by substring. +CATALOG_TAG_KINDS = {"text-to-image": ("image",), "image-to-image": ("edit",), + "text-to-video": ("video",), "image-to-video": ("video",)} + _KIND_SOURCES = ( ("image", IMAGE_TEXT_MODELS), ("edit", IMAGE_EDIT_MODELS), ("video", VIDEO_MODELS)) _STATIC_MEDIA_MODELS = frozenset(model for _, models in _KIND_SOURCES for model in models) +class MediaUpstreamError(Exception): + """An upstream reply envelope reported a non-zero business code.""" + + def __init__(self, code, message: str): + super().__init__(f"上游媒体接口返回错误 {code}: {message}") + self.upstream_code = code + self.message = message + + def model_kinds(model) -> tuple: """Return the media kinds a statically known model serves; empty for chat models.""" if not isinstance(model, str): @@ -51,16 +64,15 @@ def model_kinds(model) -> tuple: def catalog_kinds(item) -> tuple: - """Classify an account-catalog model entry by its tags; only media tags count.""" + """Classify an account-catalog model entry by its exact media tags.""" tags = item.get("tags") if isinstance(item, dict) else None if not isinstance(tags, list): return () - names = " ".join(str(tag).lower() for tag in tags if isinstance(tag, (str, int))) kinds = [] - if "image" in names: - kinds.extend(("image", "edit")) - if "video" in names: - kinds.append("video") + for tag in tags: + for kind in CATALOG_TAG_KINDS.get(tag if isinstance(tag, str) else "", ()): + if kind not in kinds: + kinds.append(kind) return tuple(kinds) @@ -89,33 +101,43 @@ def normalize_media_base_url(value): return value.rstrip("/") -def _clean_text(value, field, limit=4096) -> str: +def _clean_text(value, field, limit: int) -> str: + """Trim a short field, allowing whitespace controls and rejecting other C0 chars.""" if not isinstance(value, str) or not value.strip(): raise ValueError(f"{field} 必须是非空字符串") - if len(value) > limit or any(ord(char) < 32 for char in value): + if len(value) > limit or any(ord(char) < 32 and char not in "\n\r\t" for char in value): raise ValueError(f"{field} 长度或字符无效") return value.strip() +def _clean_prompt(value, field: str) -> str: + """Accept a prompt as-is: non-empty, within the shared bound, no odd control chars.""" + if not isinstance(value, str) or not value: + raise ValueError(f"{field} 必须是非空字符串") + if len(value) > MAX_PROMPT_CHARS or any(ord(char) < 32 and char not in "\n\r\t" for char in value): + raise ValueError(f"{field} 长度或字符无效") + return value + + def image_request_body(model: str, prompt: str, *, size=None, n=1, quality=None, images=None, input_fidelity=None) -> dict: - """Shape one /v2/images request mirroring the CLI ImageService payload.""" - body = {"model": model, "prompt": _clean_text(prompt, "prompt")} - if size is not None: - body["size"] = _clean_text(size, "size", 64) - if model in GEMINI_IMAGE_MODELS: - # The gemini family returns base64 only when asked, like the CLI asks. + """Shape one /v2/images request mirroring the CLI ImageService buildBaseRequestBody.""" + body = {"model": model, "prompt": _clean_prompt(prompt, "prompt"), + "size": _clean_text(size, "size", 64) if size is not None else DEFAULT_IMAGE_SIZE} + if model.startswith("hunyuan-"): + body["n"] = n if n else 1 + elif model.startswith("gemini-"): + pass # The CLI sends no extra fields; n and quality are ignored. + else: body["response_format"] = "b64_json" - if n and n > 1: + if n and n > 1: # The CLI omits n unless the client asked for more. body["n"] = n if quality is not None: body["quality"] = _clean_text(quality, "quality", 64) - elif n: - body["n"] = n if images is not None: if not isinstance(images, (list, tuple)) or not images: raise ValueError("images 必须是非空数组") - body["image"] = [_clean_text(item, "image_url", 262144) for item in images] + body["image"] = [_clean_text(item, "image_url", MAX_PROMPT_CHARS) for item in images] if input_fidelity is not None: body["input_fidelity"] = _clean_text(input_fidelity, "input_fidelity", 64) return body @@ -126,7 +148,7 @@ def video_request_body(model: str, *, prompt=None, seconds=None, resolution=None """Shape one /v2/videos/generations request mirroring the CLI VideoService payload.""" body = {"model": model, "watermark": True} if prompt is not None: - body["prompt"] = _clean_text(prompt, "prompt") + body["prompt"] = _clean_prompt(prompt, "prompt") if seconds is not None: if isinstance(seconds, bool) or not isinstance(seconds, (int, float)) or float(seconds).is_integer() is False: raise ValueError("seconds 必须是整数") @@ -146,12 +168,21 @@ def video_request_body(model: str, *, prompt=None, seconds=None, resolution=None if extra: body["extra_parameters"] = extra if image_url is not None: - body["image_url"] = _clean_text(image_url, "image_url", 262144) + body["image_url"] = _clean_text(image_url, "image_url", MAX_PROMPT_CHARS) if "prompt" not in body and "image_url" not in body: raise ValueError("prompt 与 image_url 至少提供一个") return body +def check_envelope(payload) -> None: + """Reject a business-error envelope: ``code`` present and non-zero fails the reply.""" + if isinstance(payload, dict) and payload.get("code") not in (None, 0): + message = payload.get("msg") or payload.get("message") or "" + if not isinstance(message, str): + message = str(message) + raise MediaUpstreamError(payload.get("code"), message.strip()[:500] or "上游媒体接口错误") + + def _data_field(payload: dict): data = payload.get("data") if isinstance(payload, dict) else None return data if isinstance(data, dict) else {} @@ -165,6 +196,7 @@ def parse_image_reply(payload) -> tuple[list, float | None]: """ if not isinstance(payload, dict): raise ValueError("图像响应必须是 JSON 对象") + check_envelope(payload) data = _data_field(payload) items = data.get("data") if isinstance(data.get("data"), list) else payload.get("data") if not isinstance(items, list) or not items: @@ -190,6 +222,7 @@ def parse_video_submit(payload) -> str: """Return the upstream task id from a video generation reply.""" if not isinstance(payload, dict): raise ValueError("视频响应必须是 JSON 对象") + check_envelope(payload) task_id = _data_field(payload).get("id") or payload.get("id") if not isinstance(task_id, str) or not task_id.strip(): raise ValueError("视频响应缺少任务 ID") @@ -204,22 +237,26 @@ def map_video_status(status) -> str: def parse_video_task(payload) -> tuple[str, list, float | None, str]: - """Return (public status, resource urls, credit, failure text) from a video task reply.""" + """Return (public status, resource urls, credit, failure text) from a video task reply. + + Failure text comes only from the task-level ``data`` object; the envelope ``msg`` + on the outer level is a transport-level "ok" and never describes the task. + """ if not isinstance(payload, dict): raise ValueError("视频任务响应必须是 JSON 对象") + check_envelope(payload) data = _data_field(payload) status = map_video_status(data.get("status") or payload.get("status")) rows = data.get("data") if isinstance(data.get("data"), list) else payload.get("data") urls = [item.get("url") for item in rows or [] if isinstance(item, dict) and isinstance(item.get("url"), str) and item["url"]] message = "" - for source in (data, payload): - for key in ("error", "message", "msg", "fail_reason"): - value = source.get(key) if isinstance(source, dict) else None - if isinstance(value, str) and value.strip(): - message = value.strip()[:500] - break - if message: + for key in ("error", "message", "fail_reason"): + value = data.get(key) + if isinstance(value, dict): + value = value.get("message") + if isinstance(value, str) and value.strip(): + message = value.strip()[:500] break for source in (data.get("usage"), payload.get("usage")): if isinstance(source, dict) and isinstance(source.get("credit"), (int, float)): diff --git a/app/media_store.py b/app/media_store.py index 2003226..185e7fb 100644 --- a/app/media_store.py +++ b/app/media_store.py @@ -1,8 +1,10 @@ """Local media assets and video task metadata in one sqlite-backed store. Files live under ``media/images|videos//.`` and are committed -atomically; metadata stays out of ControlStore snapshots and backups. Writing -first prunes expired objects and then evicts the oldest until the total fits. +atomically from ``media/.incoming``; heavy work (sniffing, hashing, writing the +pending file) stays outside the store lock. Metadata stays out of ControlStore +snapshots and backups. Retention is always derived from the current TTL setting, +so shrinking it applies to existing assets immediately. """ from __future__ import annotations @@ -16,8 +18,10 @@ from pathlib import Path DB_NAME = "media.sqlite3" +SCHEMA_VERSION = 1 # v1 dropped assets.expires; retention is derived. DEFAULT_PAGE_SIZE = 50 MAX_PAGE_SIZE = 200 +INCOMING_DIR = ".incoming" # Fixed magic numbers, sniffed before anything is committed to disk. _MAGIC = ( @@ -33,6 +37,9 @@ "video": ("video/mp4", "video/webm")} KIND_PREFIX = {"image": "img_", "video": "vid_"} +# Distinguishes "leave error unchanged" from an explicit error=None to clear it. +UNSET = object() + def sniff(data: bytes): """Identify an allowed media type from its header; None means rejection.""" @@ -48,6 +55,14 @@ def sniff(data: bytes): return None +def is_expired(meta: dict, ttl_hours: float) -> bool: + """Retention is derived from the current TTL, never stored per asset.""" + try: + return float(meta.get("created") or 0) + float(ttl_hours) * 3600 <= time.time() + except (TypeError, ValueError): + return True + + def security_headers(meta: dict) -> dict: """Serving headers shared by the public and admin read routes.""" return {"ETag": f'"{meta["sha256"]}"', "Cache-Control": "public, max-age=31536000, immutable", @@ -56,7 +71,7 @@ def security_headers(meta: dict) -> dict: def _row_to_asset(row) -> dict: - keys = ("id", "kind", "mime", "size", "sha256", "model", "created", "expires") + keys = ("id", "kind", "mime", "size", "sha256", "model", "created") return dict(zip(keys, row)) @@ -74,14 +89,20 @@ def __init__(self, root: Path): self.directory = self.root / "media" self.path = self.root / DB_NAME self.directory.mkdir(parents=True, exist_ok=True) + (self.directory / INCOMING_DIR).mkdir(exist_ok=True) self._lock = threading.RLock() self._db = sqlite3.connect(self.path, check_same_thread=False) self._db.execute("PRAGMA journal_mode=WAL") self._db.execute("PRAGMA synchronous=NORMAL") + self._create_schema() + self._migrate_schema() + self._db.commit() + + def _create_schema(self): self._db.executescript(""" CREATE TABLE IF NOT EXISTS assets( id TEXT PRIMARY KEY, kind TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, - sha256 TEXT NOT NULL, model TEXT, created REAL NOT NULL, expires REAL NOT NULL); + sha256 TEXT NOT NULL, model TEXT, created REAL NOT NULL); CREATE INDEX IF NOT EXISTS assets_created ON assets(created); CREATE TABLE IF NOT EXISTS tasks( task_id TEXT PRIMARY KEY, account_key TEXT NOT NULL, profile TEXT NOT NULL, @@ -89,6 +110,16 @@ def __init__(self, root: Path): media_id TEXT, error TEXT, credit REAL); CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created); """) + + def _migrate_schema(self): + """v0 databases stored a per-row expires column; retention is derived now.""" + version = self._db.execute("PRAGMA user_version").fetchone()[0] + if version >= SCHEMA_VERSION: + return + columns = {row[1] for row in self._db.execute("PRAGMA table_info(assets)").fetchall()} + if "expires" in columns: + self._db.execute("ALTER TABLE assets DROP COLUMN expires") + self._db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") self._db.commit() def close(self): @@ -110,44 +141,50 @@ def _new_id(self, kind: str) -> str: def _total_bytes(self) -> int: return self._db.execute("SELECT COALESCE(SUM(size),0) FROM assets").fetchone()[0] + def _write_pending(self, ext: str, data: bytes, digest: str) -> Path: + """Write the payload to .incoming outside the store lock (heavy work).""" + pending = self.directory / INCOMING_DIR / f"{digest[:16]}-{secrets.token_hex(8)}.{ext}" + pending.write_bytes(data) + return pending + def save_asset(self, kind: str, data: bytes, *, model=None, ttl_hours: float, max_total_bytes: int) -> dict: - """Sniff, prune and evict, then commit one asset atomically.""" + """Sniff, hash and stage outside the lock, then commit atomically inside it.""" if kind not in KIND_MIMES or not isinstance(data, bytes) or not data: raise ValueError("媒体类型或内容无效") identified = sniff(data) if identified is None or identified[1] not in KIND_MIMES[kind]: raise ValueError("文件头不在允许的媒体类型白名单内") ext, mime = identified - with self._lock: - now = time.time() - self.cleanup(ttl_hours=ttl_hours, max_total_bytes=max(0, max_total_bytes - len(data))) - for _ in range(4): # A collision keeps the existing object untouched. - asset_id = self._new_id(kind) - target = self._asset_path(asset_id, ext) - if not target.exists(): - break - else: - raise RuntimeError("无法分配新的媒体 ID") - target.parent.mkdir(parents=True, exist_ok=True) - pending = target.with_name(".incoming-" + target.name) - pending.write_bytes(data) - try: + digest = hashlib.sha256(data).hexdigest() + pending = self._write_pending(ext, data, digest) + try: + with self._lock: + stale = self._cleanup_locked(ttl_hours=ttl_hours, + max_total_bytes=max(0, max_total_bytes - len(data))) + for _ in range(4): # A collision keeps the existing object untouched. + asset_id = self._new_id(kind) + target = self._asset_path(asset_id, ext) + if not target.exists(): + break + else: + raise RuntimeError("无法分配新的媒体 ID") + target.parent.mkdir(parents=True, exist_ok=True) os.replace(pending, target) - except OSError: - pending.unlink(missing_ok=True) - raise - try: - self._db.execute( - "INSERT INTO assets VALUES(?,?,?,?,?,?,?,?)", - (asset_id, kind, mime, len(data), hashlib.sha256(data).hexdigest(), - model, now, now + ttl_hours * 3600)) - self._db.commit() - except sqlite3.Error: - target.unlink(missing_ok=True) - raise - row = self._db.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() - return _row_to_asset(row) + try: + self._db.execute( + "INSERT INTO assets VALUES(?,?,?,?,?,?,?)", + (asset_id, kind, mime, len(data), digest, model, time.time())) + self._db.commit() + except sqlite3.Error: + target.unlink(missing_ok=True) + raise + row = self._db.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() + meta = _row_to_asset(row) + self._unlink(stale) + return meta + finally: + pending.unlink(missing_ok=True) def get_asset(self, asset_id: str): with self._lock: @@ -161,20 +198,39 @@ def asset_file(self, meta: dict) -> Path | None: path = self._asset_path(meta["id"], ext) return path if path.is_file() else None - def delete_asset(self, asset_id: str) -> bool: - """Remove one file and row; bound tasks keep their status with media_id cleared.""" - with self._lock: + def _delete_rows(self, asset_ids) -> list: + """Delete metadata inside the lock and return the files to unlink outside.""" + paths = [] + for asset_id in asset_ids: row = self._db.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() if row is None: - return False + continue meta = _row_to_asset(row) self._db.execute("UPDATE tasks SET media_id=NULL WHERE media_id=?", (asset_id,)) self._db.execute("DELETE FROM assets WHERE id=?", (asset_id,)) - self._db.commit() file = self.asset_file(meta) if file is not None: - file.unlink(missing_ok=True) - return True + paths.append(file) + self._db.commit() + return paths + + def delete_asset(self, asset_id: str) -> bool: + """Remove one file and row; bound tasks keep their status with media_id cleared.""" + with self._lock: + exists = self._db.execute( + "SELECT 1 FROM assets WHERE id=?", (asset_id,)).fetchone() is not None + paths = self._delete_rows([asset_id]) if exists else [] + self._unlink(paths) + return exists + + @staticmethod + def _unlink(paths) -> None: + for file in paths: + file.unlink(missing_ok=True) + try: + file.parent.rmdir() + except OSError: + pass def list_assets(self, kind=None, cursor=None, limit=DEFAULT_PAGE_SIZE) -> dict: limit = max(1, min(int(limit), MAX_PAGE_SIZE)) @@ -212,43 +268,49 @@ def usage(self) -> dict: total += size return {"total_bytes": total, "images": by_kind["image"], "videos": by_kind["video"]} + def _cleanup_locked(self, *, ttl_hours: float, max_total_bytes: int) -> list: + """Drop expired and evicted rows under the lock; files are unlinked by the caller.""" + now = time.time() + expired = [row[0] for row in self._db.execute( + "SELECT id FROM assets WHERE created<=?", (now - float(ttl_hours) * 3600,)).fetchall()] + paths = self._delete_rows(expired) + self._db.execute("DELETE FROM tasks WHERE created<=?", (now - float(ttl_hours) * 3600,)) + while self._total_bytes() > max_total_bytes: + row = self._db.execute( + "SELECT id FROM assets ORDER BY created ASC, id ASC LIMIT 1").fetchone() + if row is None: + break + paths.extend(self._delete_rows([row[0]])) + self._db.commit() + return paths + def cleanup(self, *, ttl_hours: float, max_total_bytes: int) -> dict: """Drop expired assets and task rows, then evict the oldest to fit the quota.""" - removed = {"expired": 0, "evicted": 0} - now = time.time() with self._lock: - expired = self._db.execute( - "SELECT * FROM assets WHERE expires<=?", (now,)).fetchall() - for row in expired: - self.delete_asset(_row_to_asset(row)["id"]) - removed["expired"] = len(expired) - self._db.execute("DELETE FROM tasks WHERE created<=?", (now - ttl_hours * 3600,)) - self._db.commit() - while self._total_bytes() > max_total_bytes: - row = self._db.execute( - "SELECT * FROM assets ORDER BY created ASC, id ASC LIMIT 1").fetchone() - if row is None: - break - self.delete_asset(_row_to_asset(row)["id"]) - removed["evicted"] += 1 - self._db.commit() - return removed + before = self._db.execute("SELECT COUNT(*) FROM assets").fetchone()[0] + paths = self._cleanup_locked(ttl_hours=ttl_hours, max_total_bytes=max_total_bytes) + after = self._db.execute("SELECT COUNT(*) FROM assets").fetchone()[0] + self._unlink(paths) + return {"expired_or_evicted": before - after, "removed_files": len(paths)} # -- video tasks -------------------------------------------------------------------- - def create_task(self, task_id: str, account_key: str, profile: str, model) -> None: + def create_task(self, task_id: str, account_key: str, profile: str, model) -> dict | None: with self._lock: now = time.time() self._db.execute("INSERT OR IGNORE INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?)", (task_id, account_key, profile, model, now, now, "queued", None, None, None)) self._db.commit() + return self.get_task(task_id) def get_task(self, task_id: str): with self._lock: row = self._db.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone() return _row_to_task(row) if row else None - def update_task(self, task_id: str, *, status=None, media_id=None, error=None, credit=None) -> None: + def update_task(self, task_id: str, *, status=None, media_id=None, error=UNSET, + credit=None) -> None: + """Update selected fields; pass error=None explicitly to clear a stored failure.""" with self._lock: current = self.get_task(task_id) if current is None: @@ -258,7 +320,7 @@ def update_task(self, task_id: str, *, status=None, media_id=None, error=None, c (time.time(), status if status is not None else current["status"], media_id if media_id is not None else current["media_id"], - error if error is not None else current["error"], + current["error"] if error is UNSET else error, credit if credit is not None else current["credit"], task_id)) self._db.commit() diff --git a/app/settings.py b/app/settings.py index 19de667..02092c5 100644 --- a/app/settings.py +++ b/app/settings.py @@ -170,6 +170,7 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= env="CODEBUDDY2API_MEDIA_MAX_TOTAL_MB", minimum=64, maximum=1048576), "media_ttl_hours": _item(72, "integer", "媒体保留时长(小时)", env="CODEBUDDY2API_MEDIA_TTL_HOURS", minimum=1, maximum=8760), + # Interface preference: WebUI and `--lang` write it, nothing maps it from the environment. "language": _item("auto", "string", "界面语言", choices=["auto", "zh", "en"], env_scope="ui", lockable=False), # One-time migration notice; written by `config migrate` and the WebUI dismiss button. diff --git a/converter.py b/converter.py index 04cd9e2..8e87387 100644 --- a/converter.py +++ b/converter.py @@ -6,6 +6,7 @@ import argparse import asyncio +import weakref import hashlib import itertools import json @@ -2604,8 +2605,9 @@ def _housekeep_once(pool: CredentialPool, ledger, *, pending_only=False): try: removed = media.cleanup(ttl_hours=CONFIG.get("media_ttl_hours", 72), max_total_bytes=int(CONFIG.get("media_max_total_mb") or 1024) << 20) - if removed.get("expired") or removed.get("evicted"): - _log(f"[housekeeper] 媒体清理:过期 {removed['expired']} 项,淘汰 {removed['evicted']} 项") + if removed.get("expired_or_evicted"): + _log(f"[housekeeper] 媒体清理:移除 {removed['expired_or_evicted']} 项资产、" + f"{removed.get('removed_files', 0)} 个文件") except Exception as error: _log(f"[housekeeper] 媒体清理失败: {_network_error_text(error)}") ids = pool.begin_sync(all_entries=not pending_only) @@ -4955,9 +4957,24 @@ async def _web_fetch(url: str, prompt: str = "", max_length: int | None = None, class MediaDownloadError(RuntimeError): - """An upstream resource download failed or exceeded the local media quota.""" + """A deterministic download failure: bad signature, refused type or over quota.""" +class MediaDownloadRetryable(RuntimeError): + """A transient download failure; the next query may retry the same resource.""" + + +# Per-task refresh locks: one concurrent upstream query and download per task id. +_MEDIA_TASK_LOCKS: "weakref.WeakValueDictionary[str, asyncio.Lock]" = weakref.WeakValueDictionary() + + +def _media_task_lock(task_id: str) -> asyncio.Lock: + lock = _MEDIA_TASK_LOCKS.get(task_id) + if lock is None: + lock = asyncio.Lock() + _MEDIA_TASK_LOCKS[task_id] = lock + return lock + @dataclass(frozen=True) class _MediaAction: """One upstream media endpoint: where it lives and how long it may take.""" @@ -5004,12 +5021,13 @@ def _media_public_url(meta: dict) -> str: def _media_dynamic_kinds(model) -> list: - """Collect media kinds declared for this model across published account catalogs.""" - kinds: list = [] + """Media kinds declared for this model in account root tables (serves scope).""" + index = _catalog_index() + if index is not None: + return list(index.media_kinds.get(model, ())) + kinds: list = [] # No index yet: scan the published catalogs once. for account in (CONFIG.get("account_catalogs") or {}).values(): - if not isinstance(account, dict): - continue - for item in account.get("models") or []: + for item in ((account or {}).get("serves") or []) if isinstance(account, dict) else []: if isinstance(item, dict) and item.get("id") == model: kinds.extend(kind for kind in media_generation.catalog_kinds(item) if kind not in kinds) return kinds @@ -5026,7 +5044,7 @@ def _media_all_kinds(model) -> list: def _media_chat_guard(model) -> None: """Keep media models out of chat routing with one shared check.""" - if model and _media_all_kinds(model): + if model and (media_generation.model_kinds(model) or _media_dynamic_kinds(model)): raise HTTPException(status_code=400, detail={"error": { "message": f"模型 {model} 是媒体生成模型,不走聊天接口;{_MEDIA_ENDPOINT_HINT}", "type": "invalid_request_error", "code": "media_model_not_chat"}}) @@ -5042,7 +5060,7 @@ def _media_check_model(model, kind: str) -> None: def _media_account_serves(account, model) -> bool: """Account catalog membership with a static-list fallback for unsynced accounts.""" - items = _account_scope(account, "models") if isinstance(account, dict) else None + items = account.get("serves") if isinstance(account, dict) else None if items is None: return bool(media_generation.model_kinds(model)) return any(isinstance(item, dict) and item.get("id") == model for item in items) @@ -5170,6 +5188,12 @@ async def _media_call(action: _MediaAction, body: dict, rid: str, *, model, pars raw = await response.aread() try: result = parse(json.loads(raw.decode("utf-8", "replace"))) + except media_generation.MediaUpstreamError as error: + # A business refusal may already be billed; never replay it on another account. + observe_failure("media_upstream_refused") + raise HTTPException(status_code=502, detail={"error": { + "message": f"上游媒体接口返回错误 {error.upstream_code}:{error.message}", + "type": "upstream_error", "upstream_code": error.upstream_code}}) from None except ValueError as error: observe_failure("bad_media_response") raise HTTPException(status_code=502, detail={"error": { @@ -5213,13 +5237,17 @@ async def _media_download_bytes(url: str, rid: str) -> bytes: try: async with client.stream("GET", url) as response: if response.status_code != 200: - raise MediaDownloadError(f"资源下载失败(HTTP {response.status_code})") + if response.status_code >= 500: + raise MediaDownloadRetryable(f"资源下载失败(HTTP {response.status_code})") + detail = ",签名可能已过期" if response.status_code in (403, 404) else "" + raise MediaDownloadError( + f"资源下载失败(HTTP {response.status_code}{detail})") async for chunk in response.aiter_bytes(64 * 1024): data.extend(chunk) if len(data) > limit: raise MediaDownloadError("单个资源超过媒体容量上限") except httpx.HTTPError as error: - raise MediaDownloadError(f"资源下载失败:{type(error).__name__}") from None + raise MediaDownloadRetryable(f"资源下载失败:{type(error).__name__}") from None return bytes(data) @@ -5238,48 +5266,77 @@ async def _media_save(kind: str, data: bytes, *, model, rid: str) -> dict: async def _media_video_refresh(task_id: str, rid: str) -> dict: - """Query one upstream video task on its bound account and persist the result.""" + """Query one upstream video task on its bound account and persist the result. + + One refresh lock per task keeps concurrent queries from downloading twice; the + task row is re-read under the lock so only the winner talks to the upstream. + """ store = _media_store() - task = store.get_task(task_id) + + async def load(): + return await run_in_threadpool(store.get_task, task_id) + + task = await load() if task is None: raise HTTPException(status_code=404, detail={"error": { "message": "视频任务不存在或已过期", "type": "not_found_error", "code": "task_not_found"}}) if task["status"] in ("completed", "failed"): return task # Repeated queries keep returning the same resource or error. - cred, headers = await run_in_threadpool(_media_bound_cred, task["account_key"]) - status, urls, credit, message = await _media_call( - MEDIA_VIDEO_QUERY, {"task_id": task_id}, rid, model=task["model"], - parse=media_generation.parse_video_task, cred=cred, headers=headers, allow_failover=False) - if status == "failed": - store.update_task(task_id, status="failed", - error=message or "视频生成失败", credit=credit) - elif status == "completed": - media_id = task["media_id"] - if media_id is None and urls: - try: - meta = await _media_save("video", await _media_download_bytes(urls[0], rid), - model=task["model"], rid=rid) - media_id = meta["id"] - except MediaDownloadError as error: - _log(f"[{rid}] ✗ 视频资源下载失败 | {task_id} | {error}") - store.update_task(task_id, status="completed", - error=f"资源下载失败:{error}", credit=credit) - return store.get_task(task_id) - store.update_task(task_id, status="completed", media_id=media_id, credit=credit) - else: - store.update_task(task_id, status=status, credit=credit) - return store.get_task(task_id) - - -def _media_video_response(task: dict) -> dict: - """Render one task row in the public video shape.""" + async with _media_task_lock(task_id): + task = await load() + if task is None: + raise HTTPException(status_code=404, detail={"error": { + "message": "视频任务不存在或已过期", "type": "not_found_error", "code": "task_not_found"}}) + if task["status"] in ("completed", "failed"): + return task + cred, headers = await run_in_threadpool(_media_bound_cred, task["account_key"]) + status, urls, credit, message = await _media_call( + MEDIA_VIDEO_QUERY, {"task_id": task_id}, rid, model=task["model"], + parse=media_generation.parse_video_task, cred=cred, headers=headers, allow_failover=False) + media_id = None + if status == "failed": + await run_in_threadpool(store.update_task, task_id, status="failed", + error=message or "视频生成失败", credit=credit) + elif status == "completed": + media_id = task["media_id"] + if media_id is None and not urls: + # The upstream finished without a resource: nothing to retry. + await run_in_threadpool(store.update_task, task_id, status="failed", + error="上游未返回视频地址", credit=credit) + elif media_id is None: + try: + meta = await _media_save("video", await _media_download_bytes(urls[0], rid), + model=task["model"], rid=rid) + media_id = meta["id"] + except MediaDownloadError as error: # Deterministic: signature gone or quota. + _log(f"[{rid}] ✗ 视频资源下载失败 | {task_id} | {error}") + await run_in_threadpool(store.update_task, task_id, status="failed", + error=f"视频资源下载失败:{error}", credit=credit) + except ValueError: # Sniffer rejected the payload (#8): terminal. + await run_in_threadpool(store.update_task, task_id, status="failed", + error="资源类型不在允许的白名单内", credit=credit) + except MediaDownloadRetryable as error: + # Transient network trouble: stay in progress and retry on the next query. + _log(f"[{rid}] ↻ 视频资源下载暂缓 | {task_id} | {error}") + await run_in_threadpool(store.update_task, task_id, + error=f"资源下载暂缓,下次查询重试:{error}", credit=credit) + if status == "completed" and media_id is not None: + await run_in_threadpool(store.update_task, task_id, status="completed", + media_id=media_id, error=None, credit=credit) + elif status not in ("completed", "failed"): + await run_in_threadpool(store.update_task, task_id, status=status, credit=credit) + return await load() + + +def _media_video_response(task: dict, meta) -> dict: + """Render one task row in the public video shape; meta is the pre-fetched asset.""" body = {"id": task["task_id"], "object": "video", "status": task["status"], "model": task["model"], "created_at": int(task["created"])} if task["status"] == "completed": - meta = _media_store().get_asset(task["media_id"]) if task["media_id"] else None - if meta is not None and _media_base_url(): + expired = meta is not None and media_store.is_expired(meta, _media_ttl_hours()) + if meta is not None and not expired and _media_base_url(): body["video"] = {"url": _media_public_url(meta)} - elif meta is not None: + elif meta is not None and not expired: body["message"] = "视频已保存到网关本地;设置资源访问地址(media_public_base_url)后可获取链接" elif task["error"]: body["message"] = task["error"] @@ -5287,6 +5344,8 @@ def _media_video_response(task: dict) -> dict: body["message"] = "视频资源已删除或过期,不再重新下载" elif task["status"] == "failed": body["error"] = {"message": task["error"] or "视频生成失败"} + else: + body["message"] = task["error"] # A retryable download hint, if any. if task["credit"] is not None: body["usage"] = {"credit": task["credit"]} return body @@ -5307,41 +5366,59 @@ async def _media_json(request: Request) -> dict: return payload -def _media_optional_text(value, field: str, limit: int = 262144): +def _media_optional_text(value, field: str, limit: int = media_generation.MAX_PROMPT_CHARS): """Accept an optional public string field; None passes through untouched.""" if value is None: return None - if not isinstance(value, str) or len(value) > limit or any(ord(c) < 32 for c in value): + if (not isinstance(value, str) or len(value) > limit + or any(ord(c) < 32 and c not in "\n\r\t" for c in value)): raise _media_bad_request(f"{field} 必须是不超过 {limit} 字符的字符串") return value +async def _media_image_item(item: dict, model, rid: str, *, persist: bool): + """Convert one upstream item; returns (payload, persisted_id, error).""" + revised = {"revised_prompt": item["revised_prompt"]} if item.get("revised_prompt") else {} + try: + if item.get("b64_json"): + if persist: + meta = await _media_save("image", base64.b64decode(item["b64_json"]), + model=model, rid=rid) + return {"url": _media_public_url(meta), **revised}, meta["id"], None + return {"b64_json": item["b64_json"], **revised}, None, None + data = await _media_download_bytes(item["url"], rid) + if persist: + meta = await _media_save("image", data, model=model, rid=rid) + return {"url": _media_public_url(meta), **revised}, meta["id"], None + return {"b64_json": base64.b64encode(data).decode("ascii"), **revised}, None, None + except (MediaDownloadError, MediaDownloadRetryable, ValueError) as error: + return None, None, str(error) + + async def _media_image_reply(items: list, model, rid: str, *, persist: bool) -> list: - """Convert upstream image items to the public shape, persisting only when serving URLs.""" - out = [] - for item in items: - revised = {"revised_prompt": item["revised_prompt"]} if item.get("revised_prompt") else {} - try: - if item.get("b64_json"): - if persist: - meta = await _media_save("image", base64.b64decode(item["b64_json"]), - model=model, rid=rid) - out.append({"url": _media_public_url(meta), **revised}) - else: - out.append({"b64_json": item["b64_json"], **revised}) - else: - data = await _media_download_bytes(item["url"], rid) - if persist: - meta = await _media_save("image", data, model=model, rid=rid) - out.append({"url": _media_public_url(meta), **revised}) - else: - out.append({"b64_json": base64.b64encode(data).decode("ascii"), **revised}) - except MediaDownloadError as error: - raise HTTPException(status_code=502, detail={"error": { - "message": str(error), "type": "upstream_error"}}) from None - except ValueError as error: - raise HTTPException(status_code=502, detail={"error": { - "message": f"图像内容无效,无法保存或编码:{error}", "type": "upstream_error"}}) from None + """Download all image items concurrently; keep successes and never orphan files.""" + results = await asyncio.gather(*(_media_image_item(item, model, rid, persist=persist) + for item in items[:media_generation.MAX_IMAGE_ITEMS])) + out, persisted, failures, first_error = [], [], 0, "" + for payload, asset_id, error in results: + if error is None: + out.append(payload) + if asset_id: + persisted.append(asset_id) + else: + failures += 1 + first_error = first_error or error + if failures == len(items): + store = CONFIG.get("media_store") + if store is not None and persisted: + await run_in_threadpool( + lambda: [store.delete_asset(asset_id) for asset_id in persisted]) + raise HTTPException(status_code=502, detail={"error": { + "message": f"全部 {len(items)} 张图片下载或保存失败:{first_error}", + "type": "upstream_error"}}) + if failures: + _log(f"[{rid}] ✗ 图片部分失败 {failures}/{len(items)} | {first_error}") + observe_failure("image_item_failed") return out @@ -5365,23 +5442,37 @@ def _media_model_accounts(model) -> int: def admin_media_models() -> list: - """Model table for the media page: static families plus tagged catalog entries.""" + """Model table for the media page: static families plus catalog-tagged entries.""" defaults = {"image": media_generation.DEFAULT_IMAGE_MODEL, "edit": media_generation.DEFAULT_IMAGE_EDIT_MODEL, "video": media_generation.DEFAULT_VIDEO_MODEL} - table: dict = {} + static_kinds: dict = {} for kind, models in (("image", media_generation.IMAGE_TEXT_MODELS), ("edit", media_generation.IMAGE_EDIT_MODELS), ("video", media_generation.VIDEO_MODELS)): for model in models: - table.setdefault(model, {"kinds": [], "catalog": False})["kinds"].append(kind) - for account in (CONFIG.get("account_catalogs") or {}).values(): - for item in ((account or {}).get("models") or []): - if isinstance(item, dict) and media_generation.catalog_kinds(item) \ - and isinstance(item.get("id"), str): - table.setdefault(item["id"], {"kinds": [], "catalog": False})["catalog"] = True - return [{"id": model, "kinds": entry["kinds"], - "sources": ["static"] + (["catalog"] if entry["catalog"] else []), + static_kinds.setdefault(model, []).append(kind) + catalog_kinds_map = _catalog_index().media_kinds if _catalog_index() is not None else None + if catalog_kinds_map is None: + catalog_kinds_map = {} # No index yet: derive kinds from the published serves scan. + for account in (CONFIG.get("account_catalogs") or {}).values(): + for item in ((account or {}).get("serves") or []) if isinstance(account, dict) else []: + if (isinstance(item, dict) and isinstance(item.get("id"), str) + and media_generation.catalog_kinds(item)): + known = catalog_kinds_map.setdefault(item["id"], ()) + catalog_kinds_map[item["id"]] = tuple( + dict.fromkeys(known + media_generation.catalog_kinds(item))) + table = {model: {"kinds": tuple(kinds), "catalog": False} + for model, kinds in static_kinds.items()} + for model, kinds in catalog_kinds_map.items(): + if not kinds: + continue # Only tagged media entries join the table. + entry = table.setdefault(model, {"kinds": (), "catalog": False}) + entry["catalog"] = True + entry["kinds"] = tuple(dict.fromkeys(entry["kinds"] + kinds)) + return [{"id": model, "kinds": list(entry["kinds"]), + "sources": (["static"] if model in static_kinds else []) + + (["catalog"] if entry["catalog"] else []), "default": [kind for kind, name in defaults.items() if name == model], "accounts": _media_model_accounts(model)} for model, entry in sorted(table.items())] @@ -5825,7 +5916,7 @@ async def images_generations(request: Request, payload = await _media_json(request) model = payload.get("model") or media_generation.DEFAULT_IMAGE_MODEL _media_check_model(model, "image") - if not isinstance(payload.get("prompt"), str) or not payload["prompt"].strip(): + if not isinstance(payload.get("prompt"), str) or not payload["prompt"]: raise _media_bad_request("prompt 必须是非空字符串") n, problem = _validated_count(payload.get("n"), 1, 1, media_generation.MAX_IMAGE_ITEMS, "n") if problem: @@ -5834,8 +5925,13 @@ async def images_generations(request: Request, raise _media_bad_request("response_format 必须是 url 或 b64_json") size = _media_optional_text(payload.get("size"), "size", 64) quality = _media_optional_text(payload.get("quality"), "quality", 64) - body = media_generation.image_request_body(model, payload["prompt"], size=size, n=n, quality=quality) + try: + body = media_generation.image_request_body(model, payload["prompt"], size=size, n=n, quality=quality) + except ValueError as error: + raise _media_bad_request(str(error)) from None rid = _request_id() + if bool(_media_base_url()) and payload.get("response_format") != "b64_json": + _media_store() # Refuse before the billed upstream round when persistence is impossible. _log(f"[{rid}] ▶ IMAGE | {model} | n={n} | chars={len(payload['prompt'])}") items, credit = await _media_call(MEDIA_IMAGE_GENERATE, body, rid, model=model, parse=media_generation.parse_image_reply) @@ -5856,7 +5952,7 @@ async def images_edits(request: Request, payload = await _media_json(request) model = payload.get("model") or media_generation.DEFAULT_IMAGE_EDIT_MODEL _media_check_model(model, "edit") - if not isinstance(payload.get("prompt"), str) or not payload["prompt"].strip(): + if not isinstance(payload.get("prompt"), str) or not payload["prompt"]: raise _media_bad_request("prompt 必须是非空字符串") raw_images = payload.get("images") if not isinstance(raw_images, list) or not raw_images: @@ -5874,9 +5970,14 @@ async def images_edits(request: Request, size = _media_optional_text(payload.get("size"), "size", 64) quality = _media_optional_text(payload.get("quality"), "quality", 64) fidelity = _media_optional_text(payload.get("input_fidelity"), "input_fidelity", 64) - body = media_generation.image_request_body(model, payload["prompt"], size=size, n=1, - quality=quality, images=images, input_fidelity=fidelity) + try: + body = media_generation.image_request_body(model, payload["prompt"], size=size, n=1, + quality=quality, images=images, input_fidelity=fidelity) + except ValueError as error: + raise _media_bad_request(str(error)) from None rid = _request_id() + if bool(_media_base_url()) and payload.get("response_format") != "b64_json": + _media_store() # Refuse before the billed upstream round when persistence is impossible. _log(f"[{rid}] ▶ IMAGE EDIT | {model} | images={len(images)} | chars={len(payload['prompt'])}") items, credit = await _media_call(MEDIA_IMAGE_EDIT, body, rid, model=model, parse=media_generation.parse_image_reply) @@ -5922,9 +6023,8 @@ async def create_video(request: Request, note = {} task_id = await _media_call(MEDIA_VIDEO_SUBMIT, body, rid, model=model, parse=media_generation.parse_video_submit, lease_note=note) - await run_in_threadpool(store.create_task, task_id, note.get("credential"), - note.get("profile"), model) - task = store.get_task(task_id) + task = await run_in_threadpool(store.create_task, task_id, note.get("credential"), + note.get("profile"), model) return {"id": task_id, "object": "video", "status": "queued", "model": model, "created_at": int((task or {}).get("created") or time.time())} @@ -5936,7 +6036,10 @@ async def get_video(task_id: str, """Report one video task, refreshing in-flight state from its bound account.""" _check_auth(authorization, x_api_key) task = await _media_video_refresh(task_id, _request_id()) - return _media_video_response(task) + meta = None + if task["status"] == "completed" and task["media_id"]: + meta = await run_in_threadpool(_media_store().get_asset, task["media_id"]) + return _media_video_response(task, meta) @app.get("/v1/videos/{task_id}/content") @@ -5952,7 +6055,9 @@ async def get_video_content(task_id: str, task = await _media_video_refresh(task_id, _request_id()) meta = None if task["status"] == "completed" and task["media_id"]: - meta = _media_store().get_asset(task["media_id"]) + meta = await run_in_threadpool(_media_store().get_asset, task["media_id"]) + if meta is not None and media_store.is_expired(meta, _media_ttl_hours()): + meta = None if meta is None: raise HTTPException(status_code=409, detail={"error": { "message": "视频资源尚不可用(任务未完成或资源已清理)", @@ -5971,8 +6076,13 @@ async def media_asset(kind: str, asset_id: str, request: Request): if store is None: raise HTTPException(status_code=503, detail={"error": { "message": "媒体存储不可用", "type": "service_unavailable"}}) - meta = store.get_asset(asset_id) - file = store.asset_file(meta) if meta is not None else None + def lookup(): + meta = store.get_asset(asset_id) + if meta is not None and media_store.is_expired(meta, _media_ttl_hours()): + return None, None # Expired reads fail now, not at the next hourly sweep. + return (meta, store.asset_file(meta)) if meta is not None else (None, None) + + meta, file = await run_in_threadpool(lookup) if file is None: raise HTTPException(status_code=404, detail={"error": { "message": "媒体资源不存在或已清理", "type": "not_found_error"}}) diff --git a/docs/clients.md b/docs/clients.md index 52d3a18..77683a3 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -103,12 +103,13 @@ curl http://127.0.0.1:8787/v1/fetch -H "Authorization: Bearer YOUR_API_KEY" \ Image and video endpoints backed by the upstream `/v2/images/*` and `/v2/videos/*` services; each request is billed with upstream credits. -- `POST /v1/images/generations` — `{"prompt", "model?", "size?", "n?" (≤4), "quality?", "response_format?"}` → `{"created", "data": [{url | b64_json, revised_prompt?}], "usage"?}`. +- `POST /v1/images/generations` — `{"prompt", "model?", "size?", "n?" (≤4), "quality?", "response_format?"}` → `{"created", "data": [{url | b64_json, revised_prompt?}], "usage"?}`. Prompts are passed through verbatim, newlines included (≤32000 characters); gemini-family models ignore `n` and `quality`, matching the official CLI. - `POST /v1/images/edits` — OpenAI-style JSON: `{"prompt", "images": [{"image_url": "https://… | data:…"}], "model?", "size?", "input_fidelity?"}`; multipart is not supported. - `POST /v1/videos` — `{"prompt?" , "image_url?", "seconds?" (int or numeric string), "resolution?" ("768P"|"2K"), "aspect_ratio?", "enable_audio?", "model?"}` → `{"id", "object": "video", "status": "queued", …}`; poll with `GET /v1/videos/{id}` and fetch finished videos via `GET /v1/videos/{id}/content`. - Without the asset base URL (`media_public_base_url`, WebUI **Media**) images always return `b64_json` and saved videos expose no link; videos are always stored locally under `media/` and the link appears once the base URL is set. Assets are served publicly at `/v1/media/{images|videos}/{id}` with immutable caching. - Defaults are fixed per family: `hunyuan-image-v3.0` (text to image), `hunyuan-image-v2.0-general-edit` (edit), `minimax-video-h3` (video). Models outside the static list ∪ account catalog are rejected with 400, and media models are refused on the chat endpoints with a pointer here. Upstream stays the final judge of model authorization. - Accounts are picked per request from verified media-capable products only (currently `cn-cli`), require catalog membership and a positive balance, and a refused request (401/403/429) may be retried once on another account per the failover setting; 5xx and timeouts are never replayed to avoid double billing. Video tasks always stay bound to the account that submitted them. +- Multi-image requests download concurrently and return the surviving items when some fail; only a total failure answers 502 and leaves no stored files. Video downloads distinguish transient failures (network, timeout, upstream 5xx — the task stays active and the next query retries) from deterministic ones (expired signature 403/404, refused type, over quota — the task fails permanently and repeated queries return the same error). ## Protocol behavior worth knowing diff --git a/docs/clients.zh-CN.md b/docs/clients.zh-CN.md index 639c8ce..d01c194 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -103,12 +103,13 @@ curl http://127.0.0.1:8787/v1/fetch -H "Authorization: Bearer YOUR_API_KEY" \ 图像与视频端点,上游为 `/v2/images/*` 与 `/v2/videos/*`;每次请求按上游积分计费。 -- `POST /v1/images/generations`:`{"prompt", "model?", "size?", "n?"(≤4), "quality?", "response_format?"}` → `{"created", "data": [{url | b64_json, revised_prompt?}], "usage"?}`。 +- `POST /v1/images/generations`:`{"prompt", "model?", "size?", "n?"(≤4), "quality?", "response_format?"}` → `{"created", "data": [{url | b64_json, revised_prompt?}], "usage"?}`。prompt 原样下发,支持多行(≤32000 字符);gemini 族忽略 `n` 与 `quality`,与官方 CLI 一致。 - `POST /v1/images/edits`:OpenAI 风格 JSON:`{"prompt", "images": [{"image_url": "https://… | data:…"}], "model?", "size?", "input_fidelity?"}`;不支持 multipart。 - `POST /v1/videos`:`{"prompt?", "image_url?", "seconds?"(整数或数字字符串), "resolution?"("768P"|"2K"), "aspect_ratio?", "enable_audio?", "model?"}` → `{"id", "object": "video", "status": "queued", …}`;用 `GET /v1/videos/{id}` 轮询,`GET /v1/videos/{id}/content` 获取完成的视频。 - 未配置资源访问地址(`media_public_base_url`,WebUI「媒体生成」页)时,图片一律返回 `b64_json`,已保存的视频不回传链接;视频始终落盘到 `media/`,配置后即可获得链接。资源经 `/v1/media/{images|videos}/{id}` 公开读取,带不可变缓存头。 - 默认模型按族固定:文生图 `hunyuan-image-v3.0`、图生图 `hunyuan-image-v2.0-general-edit`、视频 `minimax-video-h3`。静态清单 ∪ 账号目录之外的模型返回 400;媒体模型打到聊天端点会被拒绝并指引到本节。授权类拒绝以上游为最终裁决。 - 按请求选号,仅限已实测可用的媒体产品(当前为 `cn-cli`),要求目录包含该模型且余额为正;被明确拒绝(401/403/429)时可按 failover 配置换号重试一次,5xx 与超时一律不重放以避免重复扣费。视频任务始终绑定提交账号查询。 +- 多张图片并发下载,部分失败时返回成功的条目;全部失败才返回 502 且不残留已落盘文件。视频下载区分可重试失败(网络、超时、上游 5xx:任务保持进行中,下次查询重试)与确定性失败(签名过期 403/404、类型被拒、超容量:任务记为失败,重复查询返回同一错误)。 ## 值得了解的协议行为 diff --git a/docs/webui.md b/docs/webui.md index b13ae8e..6e7f41a 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -55,7 +55,7 @@ The **Media** page shows locally stored generation results: usage against the ca - **Library** lists assets newest first with model, size and remaining retention; a card opens the preview drawer with metadata, download and a two-step delete. **Video tasks** refresh in-flight tasks on their bound account and link finished videos back to the library. **Models** is a read-only table of media models and how many accounts could currently serve them. - Images are only stored when an asset base URL is configured; without it they are returned inline as base64. Videos are always stored locally, and their link appears once the base URL is set. -- **Media settings** (`media_public_base_url`, `media_max_total_mb`, `media_ttl_hours`) set the public link prefix, capacity (64–1048576 MB) and retention (1–8760 hours). Shrinking either evicts the oldest assets on the next write or scheduled cleanup. Assets are also served publicly at `/v1/media/…` with immutable caching; see [Media generation](clients.md#media-generation). +- **Media settings** (`media_public_base_url`, `media_max_total_mb`, `media_ttl_hours`) set the public link prefix, capacity (64–1048576 MB) and retention (1–8760 hours). Retention is derived from each asset's creation time and the current setting, so shrinking it expires existing assets immediately; the oldest are evicted on the next write or scheduled cleanup. Assets are also served publicly at `/v1/media/…` with immutable caching; see [Media generation](clients.md#media-generation). ## Settings diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index fc6f177..046674e 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -55,7 +55,7 @@ - **资源库**按时间倒序列出资源,含模型、大小与剩余保留时长;卡片打开预览抽屉,可查看元数据、下载与两步删除。**视频任务**用绑定账号刷新进行中的任务,并把完成的视频链回资源库。**模型**为只读表格,展示媒体模型与当前可用的账号数。 - 只有配置了资源访问地址,图片才会落盘;未配置时以 base64 内联返回。视频始终保存到本地,配置地址后即可获得链接。 -- **媒体设置**(`media_public_base_url`、`media_max_total_mb`、`media_ttl_hours`)分别设置公开链接前缀、容量(64–1048576 MB)与保留时长(1–8760 小时)。调小后会在下次写入或定时清理时淘汰最老的资源。资源同时经 `/v1/media/…` 公开读取,带不可变缓存头;见[媒体生成](clients.zh-CN.md#媒体生成)。 +- **媒体设置**(`media_public_base_url`、`media_max_total_mb`、`media_ttl_hours`)分别设置公开链接前缀、容量(64–1048576 MB)与保留时长(1–8760 小时)。保留时长按创建时间与当前设置推导,调小后立即对已有资源生效;容量按最老优先在下次写入或定时清理时淘汰。资源同时经 `/v1/media/…` 公开读取,带不可变缓存头;见[媒体生成](clients.zh-CN.md#媒体生成)。 ## 系统设置 diff --git a/preview/seed.py b/preview/seed.py index acd8b55..9f0c262 100644 --- a/preview/seed.py +++ b/preview/seed.py @@ -164,8 +164,7 @@ def write_media(directory, now: float | None = None): store.save_asset("image", PNG_BYTES, model="hunyuan-image-v3.0", **quota) # A second image a moment older keeps the newest-first listing meaningful. first = store.save_asset("image", PNG_BYTES_ALT, model="hy-image-3.5", **quota) - store._db.execute("UPDATE assets SET created=?, expires=? WHERE id=?", - (now - 3600, now - 3600 + 72 * 3600, first["id"])) + store._db.execute("UPDATE assets SET created=? WHERE id=?", (now - 3600, first["id"])) store._db.commit() video = store.save_asset("video", MP4_BYTES, model="minimax-video-h3", **quota) aurora = account_key("cn-cli", "100001", "") diff --git a/preview/upstream.py b/preview/upstream.py index a4d637c..be50c9f 100644 --- a/preview/upstream.py +++ b/preview/upstream.py @@ -303,14 +303,15 @@ def catalog(self, request, body, state): "descriptionZh": f"{MODELS[key]['name']}(预览数据)", "descriptionEn": f"{MODELS[key]['name']} (preview data)"} for key in state.account.models] - # Media models stay out of chat routing (supportsToolCall False) but join the - # account catalog so the gateway's media selection sees them as members. + # Media models live in the root table only (the account/serves scope) with the + # real upstream tags; the cli selector never lists them, so chat is unaffected. + kind_tags = {"image": "text-to-image", "edit": "image-to-image", "video": "text-to-video"} models.extend({"id": name, "name": name, "supportsToolCall": False, - "tags": ["video" if name in media_generation.VIDEO_MODELS else "image"], + "tags": [kind_tags[kind] for kind in media_generation.model_kinds(name)], "credits": "x1.00"} for name in state.account.media) agents = [{"name": "cli", "tags": ["default"], - "models": list(state.account.models) + list(state.account.media)}] + "models": list(state.account.models)}] models[0]["isDefault"] = True return ok({"models": models, "agents": agents}) diff --git a/tests/test_media_generation.py b/tests/test_media_generation.py index be181a8..dc44548 100644 --- a/tests/test_media_generation.py +++ b/tests/test_media_generation.py @@ -5,10 +5,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. +import asyncio import base64 +import concurrent.futures import json +import sqlite3 import struct import tempfile +import threading import time import unittest from unittest.mock import AsyncMock, Mock, patch @@ -20,7 +24,7 @@ from pathlib import Path as _Path import converter -from app import media_generation +from app import media_generation, media_store from app.admin_api import install_admin from app.control_store import ControlStore from app.media_store import MediaStore @@ -56,15 +60,23 @@ def test_static_kinds_and_defaults(self): self.assertEqual(media_generation.DEFAULT_VIDEO_MODEL, "minimax-video-h3") self.assertEqual(media_generation.MEDIA_PROFILES, frozenset({"cn-cli"})) - def test_catalog_kinds_and_validation_set(self): - self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["image", "beta"]}), - ("image", "edit")) - self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["video"]}), ("video",)) - self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": []}), ()) + def test_catalog_kinds_exact_tags(self): + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["text-to-image"]}), ("image",)) + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["image-to-image"]}), ("edit",)) + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["text-to-video"]}), ("video",)) + # image-to-video serves video only, never image kinds despite the name. + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": ["image-to-video"]}), ("video",)) + self.assertEqual(media_generation.catalog_kinds( + {"id": "x", "tags": ["text-to-image", "image-to-image"]}), ("image", "edit")) + # Substrings and unknown tags never classify as media. + for tags in (["image"], ["video"], ["preview-image"], ["image-to-video-preview"], [], None): + self.assertEqual(media_generation.catalog_kinds({"id": "x", "tags": tags}), (), tags) self.assertEqual(media_generation.catalog_kinds({"id": "x"}), ()) self.assertTrue(media_generation.is_media_model("kling-video-v3")) self.assertTrue(media_generation.is_media_model( - "custom-image", [{"id": "custom-image", "tags": ["image"]}])) + "custom-image", [{"id": "custom-image", "tags": ["text-to-image"]}])) + self.assertFalse(media_generation.is_media_model( + "custom-image", [{"id": "custom-image", "tags": ["preview-image"]}])) self.assertFalse(media_generation.is_media_model( "custom-image", [{"id": "custom-image", "tags": ["beta"]}])) @@ -80,13 +92,24 @@ def test_base_url_validator(self): normalize(bad) def test_image_body_shapes(self): + # hunyuan family: n is always sent, size defaults when missing. body = media_generation.image_request_body("hunyuan-image-v3.0", "a cat", size="1024x1024", n=2) self.assertEqual(body, {"model": "hunyuan-image-v3.0", "prompt": "a cat", "size": "1024x1024", "n": 2}) - body = media_generation.image_request_body("gemini-3.0-pro-image", "a cat", quality="high") - self.assertEqual(body["response_format"], "b64_json") - self.assertEqual(body["quality"], "high") - self.assertNotIn("n", body) + self.assertEqual(media_generation.image_request_body("hunyuan-image-v3.0-art", "a cat"), + {"model": "hunyuan-image-v3.0-art", "prompt": "a cat", + "size": "1024x1024", "n": 1}) + # gemini family: the CLI sends no extra fields; n and quality are ignored. + body = media_generation.image_request_body("gemini-3.0-pro-image", "a cat", n=3, quality="high") + self.assertEqual(body, {"model": "gemini-3.0-pro-image", "prompt": "a cat", + "size": "1024x1024"}) + # Other families: response_format plus n and quality when provided. + body = media_generation.image_request_body("hy-image-3.5", "a cat", n=2, quality="high") + self.assertEqual(body, {"model": "hy-image-3.5", "prompt": "a cat", "size": "1024x1024", + "response_format": "b64_json", "n": 2, "quality": "high"}) + self.assertEqual(media_generation.image_request_body("hy-image-3.5", "a cat"), + {"model": "hy-image-3.5", "prompt": "a cat", "size": "1024x1024", + "response_format": "b64_json"}) body = media_generation.image_request_body( "hunyuan-image-v2.0-general-edit", "edit", n=1, images=["https://cos.example/a.png", "data:image/png;base64,AAA"], @@ -94,9 +117,16 @@ def test_image_body_shapes(self): self.assertEqual(body["image"], ["https://cos.example/a.png", "data:image/png;base64,AAA"]) self.assertEqual(body["input_fidelity"], "high") with self.assertRaises(ValueError): - media_generation.image_request_body("hunyuan-image-v3.0", " ") + media_generation.image_request_body("hunyuan-image-v3.0", "") with self.assertRaises(ValueError): media_generation.image_request_body("hunyuan-image-v3.0", "a cat", images=[]) + # Multiline prompts pass through unchanged; other C0 controls are rejected. + body = media_generation.image_request_body("hunyuan-image-v3.0", "line1\nline2\r\n\tindented") + self.assertEqual(body["prompt"], "line1\nline2\r\n\tindented") + with self.assertRaises(ValueError): + media_generation.image_request_body("hunyuan-image-v3.0", "bad\x01prompt") + with self.assertRaises(ValueError): + media_generation.image_request_body("hunyuan-image-v3.0", "x" * (media_generation.MAX_PROMPT_CHARS + 1)) def test_video_body_shapes(self): body = media_generation.video_request_body( @@ -138,11 +168,30 @@ def test_status_mapping_and_parsers(self): status, urls, credit, message = media_generation.parse_video_task( {"data": {"status": "failed", "error": "blocked", "usage": {"credit": 2}}}) self.assertEqual((status, urls, credit, message), ("failed", [], 2.0, "blocked")) + # An error object is unwrapped to its message field. + _, _, _, message = media_generation.parse_video_task( + {"data": {"status": "failed", "error": {"code": 1, "message": "content blocked"}}}) + self.assertEqual(message, "content blocked") + # The envelope msg is transport-level "ok" and never becomes the task failure. + _, _, _, message = media_generation.parse_video_task( + {"code": 0, "msg": "ok", "data": {"status": "processing"}}) + self.assertEqual(message, "") status, urls, credit, message = media_generation.parse_video_task( {"data": {"status": "completed", "data": [{"url": "https://a/v.mp4"}]}}) self.assertEqual((status, urls), ("completed", ["https://a/v.mp4"])) self.assertIsNone(credit) + def test_envelope_errors(self): + envelope = {"code": 10001, "msg": "余额不足"} + for parse in (media_generation.parse_image_reply, media_generation.parse_video_submit, + media_generation.parse_video_task): + with self.assertRaises(media_generation.MediaUpstreamError) as caught: + parse(envelope) + self.assertEqual(caught.exception.upstream_code, 10001) + self.assertEqual(caught.exception.message, "余额不足") + # code 0 or absent stays a normal reply. + self.assertEqual(media_generation.parse_video_submit({"code": 0, "msg": "ok", "id": "t1"}), "t1") + class StoreTests(unittest.TestCase): """MediaStore sniffing, eviction, TTL and pagination.""" @@ -199,19 +248,80 @@ def test_eviction_keeps_quota(self): self.assertIsNone(self.store.get_asset(first)) def test_ttl_cleanup_removes_assets_and_tasks(self): - meta = self.save("image", PNG_BYTES, ttl_hours=0.001) + meta = self.save("image", PNG_BYTES) self.store.create_task("t1", "acct", "cn-cli", "m") self.store.update_task("t1", status="completed", media_id=meta["id"], credit=1) - time.sleep(0.02) - # Expire both rows directly: the TTL is expressed in hours. - self.store._db.execute("UPDATE assets SET expires=?", (time.time() - 1,)) - self.store._db.execute("UPDATE tasks SET created=?", (time.time() - 72 * 3600 - 1,)) + # Retention is derived from created + the current TTL: age both rows past 72h. + self.store._db.execute("UPDATE assets SET created=?", (time.time() - 73 * 3600,)) + self.store._db.execute("UPDATE tasks SET created=?", (time.time() - 73 * 3600,)) self.store._db.commit() removed = self.store.cleanup(ttl_hours=72, max_total_bytes=1 << 30) - self.assertEqual(removed["expired"], 1) + self.assertEqual(removed["expired_or_evicted"], 1) self.assertIsNone(self.store.get_asset(meta["id"])) self.assertIsNone(self.store.get_task("t1")) + def test_is_expired_follows_current_ttl(self): + meta = self.save("image", PNG_BYTES) + self.assertFalse(media_store.is_expired(meta, 72)) + self.assertTrue(media_store.is_expired({**meta, "created": time.time() - 2 * 3600}, 1)) + self.assertTrue(media_store.is_expired({}, 72)) + + def test_reads_not_blocked_while_payload_is_staged(self): + existing = self.save("image", PNG_BYTES) + entered, release = threading.Event(), threading.Event() + + class Stalled(MediaStore): + def _write_pending(self, ext, data, digest): + entered.set() + release.wait(5) + return super()._write_pending(ext, data, digest) + + stalled = Stalled(self.root) + self.addCleanup(stalled.close) + worker = threading.Thread( + target=lambda: stalled.save_asset("image", b"\xff\xd8\xff" + b"j" * 32, + ttl_hours=72, max_total_bytes=1 << 30)) + worker.start() + self.assertTrue(entered.wait(2)) + started = time.time() # The staged write holds no lock: reads must stay fast. + self.assertEqual(stalled.get_asset(existing["id"])["id"], existing["id"]) + self.assertLess(time.time() - started, 1) + release.set() + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(stalled.usage()["images"]["count"], 2) + + def test_v0_database_drops_expires_and_keeps_rows(self): + self.store.close() + db = sqlite3.connect(self.store.path) + db.executescript(""" + DROP TABLE assets; + CREATE TABLE assets( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, + sha256 TEXT NOT NULL, model TEXT, created REAL NOT NULL, expires REAL NOT NULL); + """) + db.execute("INSERT INTO assets VALUES('img_old','image','image/png',3,'x','m',?,?)", + (time.time(), time.time() + 3600)) + db.execute("PRAGMA user_version=0") + db.commit() + db.close() + reopened = MediaStore(self.store.root) + self.addCleanup(reopened.close) + columns = {row[1] for row in reopened._db.execute("PRAGMA table_info(assets)").fetchall()} + self.assertNotIn("expires", columns) + self.assertEqual(reopened._db.execute("PRAGMA user_version").fetchone()[0], + media_store.SCHEMA_VERSION) + meta = reopened.get_asset("img_old") + self.assertEqual(meta["size"], 3) + self.assertEqual(reopened.usage()["images"]["count"], 1) + + def test_incoming_residue_is_not_counted(self): + before = self.store.usage() + stray = self.store.directory / media_store.INCOMING_DIR / "leftover.png" + stray.write_bytes(PNG_BYTES) + self.assertEqual(self.store.usage(), before) + stray.unlink() + def test_delete_clears_bound_tasks(self): meta = self.save("video", MP4_BYTES) self.store.create_task("t1", "acct", "cn-cli", "m") @@ -251,6 +361,12 @@ def setUp(self): self.media_requests = [] self.image_status = 200 self.image_statuses = [] # Per-request override; pops take priority over image_status. + self.image_envelope_error = False # Image replies become business-error envelopes. + self.image_urls = None # Explicit COS urls returned per image item. + self.video_envelope_error = False # Video task replies become error envelopes. + self.video_task_no_url = False # Completed tasks omit the resource url. + self.cos_handlers = [] # (suffix, status, content?) rules, first match wins. + self.downloads = 0 self.video_task_status = "completed" self.enterContext(patch.dict(converter.CONFIG, { "media_store": self.store, "media_public_base_url": "", @@ -268,15 +384,25 @@ def configure_media(self, *, profiles=("cn-cli", "cn-work", "intl-cli", "intl-wo def handle_upstream(self, request): path = request.url.path if request.url.host == "cos.example": + self.downloads += 1 + for suffix, status, *payload in self.cos_handlers: + if path.endswith(suffix): + if status == 200: + content = payload[0] if payload else (MP4_BYTES if path.endswith(".mp4") else PNG_BYTES) + return httpx.Response(200, content=content) + return httpx.Response(status, json={"error": {"message": "cos refused"}}) content = MP4_BYTES if path.endswith(".mp4") else PNG_BYTES return httpx.Response(200, content=content) if path in IMAGE_PATHS: self.media_requests.append(request) + if self.image_envelope_error: + return httpx.Response(200, json={"code": 10001, "msg": "route refused"}) status = self.image_statuses.pop(0) if self.image_statuses else self.image_status if status != 200: return httpx.Response(status, json={"error": {"message": "synthetic media failure"}}) + urls = self.image_urls or [f"{COS}/image-0.png"] return httpx.Response(200, json={"data": {"data": [ - {"url": f"{COS}/image-0.png", "revised_prompt": "synthetic"}], + {"url": url, "revised_prompt": "synthetic"} for url in urls], "usage": {"credit": None}}}) if path == "/v2/videos/generations": self.media_requests.append(request) @@ -286,12 +412,15 @@ def handle_upstream(self, request): return httpx.Response(200, json={"data": {"id": "task-synthetic-1"}}) if path == "/v2/videos/tasks": self.media_requests.append(request) + if self.video_envelope_error: + return httpx.Response(200, json={"code": 10005, "msg": "task query refused"}) if self.video_task_status == "failed": return httpx.Response(200, json={"data": {"status": "failed", "error": "content blocked"}}) if self.video_task_status == "processing": return httpx.Response(200, json={"data": {"status": "processing"}}) + rows = [] if self.video_task_no_url else [{"url": f"{COS}/video-1.mp4"}] return httpx.Response(200, json={"data": { - "status": "completed", "data": [{"url": f"{COS}/video-1.mp4"}], + "status": "completed", "data": rows, "usage": {"credit": 62.85}}}) return super().handle_upstream(request) @@ -344,8 +473,8 @@ def test_image_validation_errors(self): ({"prompt": "x", "model": "not-a-model"}, "model_not_found"), ({"prompt": "x", "model": "minimax-video-h3"}, "model_not_found"), ({"prompt": "x", "n": 9}, None), - ({"prompt": " ", "model": "hunyuan-image-v3.0"}, None), ({"prompt": "x", "response_format": "png"}, None), + ({"prompt": "", "model": "hunyuan-image-v3.0"}, None), ({"images": [], "prompt": "x"}, None), ] for payload, code in cases: @@ -479,18 +608,160 @@ def test_media_account_requires_profile_catalog_and_credit(self): self.configure_media(balances={"cn-cli": 0}) result = self.client.post("/v1/images/generations", json={"prompt": "x"}) self.assertEqual(result.status_code, 503) - # Catalog exists but does not list the model. - self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}) + # A synced root table (serves) without the model excludes the account (#1). + self.configure_media(balances={"cn-cli": 100}) + self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}, + serves={"cn-cli": [{"id": "shared-model", + "tags": ["text-to-image-preview"]}]}) result = self.client.post("/v1/images/generations", json={"prompt": "x"}) self.assertEqual(result.status_code, 503) - # Restore the balance (the pool rebuild also clears account catalogs). - self.configure_media(balances={"cn-cli": 100}) - # A tagged catalog entry qualifies even outside the static list. - self.account_catalogs({"cn-cli": [ - fixtures.model("shared-model"), - {"id": "custom-video-model", "supportsToolCall": False, "tags": ["video"]}]}) + # Media models only in serves (never in the chat picker) still select the + # account; a tagged catalog entry qualifies even outside the static list. + self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}, + serves={"cn-cli": [{"id": "shared-model"}, + {"id": "custom-video-model", + "supportsToolCall": False, + "tags": ["text-to-video"]}]}) self.assertEqual(self.client.post("/v1/videos", json={"prompt": "x", "model": "custom-video-model", "seconds": 4}).status_code, 200) + # An unsynced account (serves is None) falls back to the static list. + self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}) + self.assertEqual(self.client.post("/v1/images/generations", json={"prompt": "x"}).status_code, 200) + + def test_media_kinds_and_guard_use_the_serves_index(self): + # A chat model carrying an "image"-looking non-media tag stays a chat model. + self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}, + serves={"cn-cli": [ + {"id": "shared-model", "tags": ["preview-image"]}]}) + result = self.client.post("/v1/chat/completions", json={ + "model": "shared-model", "messages": [{"role": "user", "content": "hi"}]}) + self.assertNotEqual(result.status_code, 400, result.text[:200]) + # Catalog-only models report kinds and sources from the serves index. + self.account_catalogs({"cn-cli": [fixtures.model("shared-model")]}, + serves={"cn-cli": [ + {"id": "custom-video-model", "tags": ["text-to-video"]}]}) + models = {entry["id"]: entry for entry in converter.admin_media_models()} + self.assertEqual(models["custom-video-model"]["kinds"], ["video"]) + self.assertEqual(models["custom-video-model"]["sources"], ["catalog"]) + self.assertEqual(models["custom-video-model"]["default"], []) + self.assertEqual(models["hunyuan-image-v3.0"]["sources"], ["static"]) + + def test_upstream_envelope_errors(self): + # Image business refusals surface as 502 with the upstream code and never replay. + self.image_envelope_error = True + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 502) + error = result.json()["error"] + self.assertEqual(error["upstream_code"], 10001) + self.assertIn("route refused", error["message"]) + self.assertEqual(len(self.media_requests), 1) + # A video query envelope error leaves the task untouched for the next poll. + self.image_envelope_error = False + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.video_envelope_error = True + refused = self.client.get(f"/v1/videos/{task_id}") + self.assertEqual(refused.status_code, 502) + self.assertEqual(refused.json()["error"]["upstream_code"], 10005) + self.assertEqual(self.store.get_task(task_id)["status"], "queued") + self.video_envelope_error = False + recovered = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(recovered["status"], "completed") + + def test_video_whitelist_failure_is_terminal_and_downloads_once(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.cos_handlers = [(".mp4", 200, b"not a video")] + first = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(first["status"], "failed") + self.assertEqual(first["error"]["message"], "资源类型不在允许的白名单内") + self.assertEqual(self.downloads, 1) + repeat = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(repeat, first) + self.assertEqual(self.downloads, 1) + + def test_video_completed_without_url_is_terminal(self): + self.video_task_no_url = True + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + result = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(result["status"], "failed") + self.assertEqual(result["error"]["message"], "上游未返回视频地址") + + def test_concurrent_refreshes_download_one_asset(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + calls = [] + + async def slow_download(url, rid): + calls.append(url) + await asyncio.sleep(0.2) + return MP4_BYTES + + with patch.object(converter, "_media_download_bytes", side_effect=slow_download): + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(self.client.get, f"/v1/videos/{task_id}") + for _ in range(2)] + results = [future.result() for future in futures] + self.assertTrue(all(result.status_code == 200 for result in results), results) + self.assertEqual(len(calls), 1) # The per-task lock keeps one download. + self.assertEqual(self.store.usage()["videos"]["count"], 1) + + def test_retryable_download_failure_recovers_on_next_query(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.cos_handlers = [(".mp4", 503)] + held = self.client.get(f"/v1/videos/{task_id}").json() + self.assertNotIn(held["status"], ("completed", "failed")) # Not a terminal state. + self.assertIn("重试", held["message"]) + self.assertEqual(self.downloads, 1) + self.cos_handlers = [] + done = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(done["status"], "completed") + self.assertIn("message", done) # No base URL: the hint stays, no link. + self.assertNotIn("error", done) + + def test_cos_signature_expiry_is_terminal(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.cos_handlers = [(".mp4", 403)] + first = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(first["status"], "failed") + self.assertIn("签名可能已过期", first["error"]["message"]) + self.assertEqual(self.downloads, 1) + repeat = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(repeat, first) + self.assertEqual(self.downloads, 1) + + def test_partial_image_failure_returns_survivors(self): + self.enterContext(patch.dict(converter.CONFIG, {"media_public_base_url": "https://media.example.com"})) + self.image_urls = [f"{COS}/a.png", f"{COS}/b.png"] + self.cos_handlers = [("b.png", 404)] + result = self.client.post("/v1/images/generations", json={"prompt": "x", "n": 2}) + self.assertEqual(result.status_code, 200, result.text) + data = result.json()["data"] + self.assertEqual(len(data), 1) # The surviving item still serves a link. + self.assertTrue(data[0]["url"].startswith("https://media.example.com/v1/media/images/")) + self.assertEqual(self.store.usage()["images"]["count"], 1) + + def test_total_image_failure_leaves_no_orphans(self): + self.enterContext(patch.dict(converter.CONFIG, {"media_public_base_url": "https://media.example.com"})) + self.image_urls = [f"{COS}/a.png", f"{COS}/b.png"] + self.cos_handlers = [("a.png", 404), ("b.png", 500)] + result = self.client.post("/v1/images/generations", json={"prompt": "x", "n": 2}) + self.assertEqual(result.status_code, 502) + self.assertIn("全部 2 张", result.json()["error"]["message"]) + self.assertEqual(self.store.usage()["images"]["count"], 0) + + def test_store_unavailable_refuses_before_upstream(self): + self.enterContext(patch.dict(converter.CONFIG, { + "media_public_base_url": "https://media.example.com", "media_store": None})) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 503) + self.assertEqual(self.media_requests, []) # Nothing billed upstream. + + def test_expired_asset_reads_404_immediately(self): + self.enterContext(patch.dict(converter.CONFIG, {"media_public_base_url": "https://media.example.com"})) + url = self.client.post("/v1/images/generations", json={"prompt": "x"}).json()["data"][0]["url"] + path = url.removeprefix("https://media.example.com") + self.assertEqual(self.client.get(path).status_code, 200) + self.store._db.execute("UPDATE assets SET created=?", (time.time() - 73 * 3600,)) + self.store._db.commit() + self.assertEqual(self.client.get(path).status_code, 404) # Not served until the sweep. def test_no_replay_on_server_errors(self): self.image_status = 500 @@ -565,7 +836,7 @@ def setUp(self): "accounts": 2}] self.gateway._media_video_refresh = AsyncMock(return_value=self.video_task) self.gateway._media_video_response = Mock( - side_effect=lambda task: {"id": task["task_id"], "status": task["status"]}) + side_effect=lambda task, meta=None: {"id": task["task_id"], "status": task["status"]}) self.app = FastAPI() install_admin(self.app, self.config, self.gateway) self.client = self.enterContext(TestClient(self.app)) @@ -623,8 +894,12 @@ def test_task_listing_and_refresh(self): def test_unavailable_store_reports_service_error(self): self.config["media_store"] = None - result = self.client.get("/admin/media/assets", headers=self.headers) - self.assertEqual(result.status_code, 400) + for method, path in (("GET", "/admin/media/assets"), ("GET", "/admin/media/tasks"), + ("GET", "/admin/media/assets/img_x/content"), + ("DELETE", "/admin/media/assets/img_x")): + result = self.client.request(method, path, headers=self.headers) + self.assertEqual(result.status_code, 503, path) + self.assertIn("媒体存储不可用", result.json()["error"]["message"]) if __name__ == "__main__": diff --git a/web/e2e/media.spec.ts b/web/e2e/media.spec.ts index 8500047..293495b 100644 --- a/web/e2e/media.spec.ts +++ b/web/e2e/media.spec.ts @@ -44,8 +44,8 @@ function asset(id: string, kind: "image" | "video", model: string, configured: b size: 4096, sha256: "0".repeat(64), model, - created: now - 3600, - expires: now + 71 * 3600, + created_at: now - 3600, + expires_at: now + 71 * 3600, ...(configured ? { public_url: `https://media.example.com/v1/media/${kind}s/${id}` } : {}), }; } diff --git a/web/src/components.tsx b/web/src/components.tsx index f412aff..d6d6319 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -42,7 +42,8 @@ export function Icon({ name = "grid" }: { name?: string }) { "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3Zm8 3v12c0 1.7-3.6 3-8 3s-8-1.3-8-3V6m16 6c0 1.7-3.6 3-8 3s-8-1.3-8-3", database: "M12 8c4.4 0 8-1.3 8-3s-3.6-3-8-3-8 1.3-8 3 3.6 3 8 3Zm-8 2v9c0 1.7 3.6 3 8 3s8-1.3 8-3v-9M4 14.5c0 1.7 3.6 3 8 3s8-1.3 8-3", - image: "m12 3 9 4.5-9 4.5-9-4.5 9-4.5Zm-9 9 9 4.5 9-4.5M3 16l9 4.5 9-4.5", + image: + "M3 5.5h18v13H3v-13Zm3.5 10 3-3.5 2.5 3 3.5-4.5 3.5 5M8.8 9.2a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z", play: "M8 5.5v13l11-6.5-11-6.5Z", }; return ( diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index e0b88d0..c2a131d 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -498,6 +498,7 @@ const zh = { "media.preview": "资源详情", "media.copyLink": "复制链接", "media.copied": "链接已复制", + "media.copyFailed": "复制失败,请手动复制链接", "media.download": "下载", "media.delete": "删除", "media.deleteConfirm": "确认删除", @@ -1056,6 +1057,7 @@ const en: Record = { "media.preview": "Asset details", "media.copyLink": "Copy link", "media.copied": "Link copied", + "media.copyFailed": "Copy failed; copy the link manually", "media.download": "Download", "media.delete": "Delete", "media.deleteConfirm": "Confirm delete", diff --git a/web/src/media.test.tsx b/web/src/media.test.tsx index d404abf..ffe6eea 100644 --- a/web/src/media.test.tsx +++ b/web/src/media.test.tsx @@ -54,8 +54,8 @@ const assets = { size: 4096, sha256: "0".repeat(64), model: "hunyuan-image-v3.0", - created: 1789000000, - expires: 1789000000 + 71 * 3600, + created_at: 1789000000, + expires_at: 1789000000 + 71 * 3600, public_url: "https://media.example.com/v1/media/images/img_" + "a".repeat(32), }, { @@ -65,8 +65,8 @@ const assets = { size: 8192, sha256: "1".repeat(64), model: "minimax-video-h3", - created: 1789000000, - expires: 1789000000 + 180 * 60, + created_at: 1789000000, + expires_at: 1789000000 + 180 * 60, }, ], has_more: false, @@ -167,7 +167,10 @@ describe("media page", () => { it("renders asset cards and hides copy-link until a base URL exists", () => { render(); fireEvent.click(screen.getAllByRole("button", { name: /hunyuan-image-v3\.0/ })[0]!); - expect(screen.getByRole("dialog")).toBeTruthy(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeTruthy(); + expect(dialog.textContent).toContain("创建时间"); + expect(dialog.textContent).toContain("到期时间"); // Derived from the current TTL setting. expect(screen.queryByRole("button", { name: "复制链接" })).toBeNull(); expect(screen.getByRole("link", { name: "下载" })).toHaveProperty( "href", @@ -183,6 +186,42 @@ describe("media page", () => { expect(screen.getByRole("button", { name: "复制链接" })).toBeTruthy(); }); + it("reports a copy failure through i18n", async () => { + mockResources({ summary: { configured: true, base_url: "https://media.example.com" } }); + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + Object.assign(navigator, { clipboard: { writeText } }); + render(); + fireEvent.click(screen.getAllByRole("button", { name: /hunyuan-image-v3\.0/ })[0]!); + fireEvent.click(screen.getByRole("button", { name: "复制链接" })); + expect(writeText).toHaveBeenCalledWith( + "https://media.example.com/v1/media/images/img_" + "a".repeat(32), + ); + await waitFor(() => + expect(screen.getByRole("dialog").textContent).toContain("复制失败,请手动复制链接"), + ); + }); + + it("labels catalog-only model sources", () => { + mockResources({ + summary: { + models: [ + ...summary.models, + { + id: "custom-video-model", + kinds: ["video"], + sources: ["catalog"], + defaults: [], + accounts: 1, + }, + ], + }, + }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "模型" })); + expect(screen.getByText("账号目录", { exact: true })).toBeTruthy(); + expect(screen.getAllByText("静态清单").length).toBeGreaterThan(0); + }); + it("requires a second click before deleting an asset", async () => { const remove = vi.spyOn(api, "delete").mockResolvedValue({ data: {} } as never); render(); diff --git a/web/src/pages/Media.tsx b/web/src/pages/Media.tsx index 1978d4b..aa11755 100644 --- a/web/src/pages/Media.tsx +++ b/web/src/pages/Media.tsx @@ -178,8 +178,8 @@ function AssetPreview({ size: bytes(number(item.size) ?? 0), sha256: typeof item.sha256 === "string" ? item.sha256.slice(0, 12) : item.sha256, model: item.model, - created: number(item.created), - expires: number(item.expires), + created_at: number(item.created_at), + expires_at: number(item.expires_at), }} />
@@ -192,7 +192,7 @@ function AssetPreview({ .then(() => notify({ key: `media-copy-${assetId}`, tone: "good", title: t("media.copied") }), ) - .catch(() => setError(errorMessage(new Error("clipboard unavailable")))); + .catch(() => setError(t("media.copyFailed"))); }} > {t("media.copyLink")} @@ -519,7 +519,7 @@ export function Media() { (assets.data.items.length ? (
{assets.data.items.map((item) => { - const remaining = remainingLabel(item.expires); + const remaining = remainingLabel(item.expires_at); const isVideo = item.kind === "video"; return ( + +
+ + ); + } return ( @@ -204,36 +219,21 @@ function AssetPreview({ {t("media.copyLink")} )} - + {t("media.download")} - {confirming ? ( - - ) : ( - - )} +
{!configured &&

{t("media.noLinkNote")}

} - {confirming &&

{t("media.deleteHint")}

} ); } @@ -376,7 +376,7 @@ function MediaSettingsDrawer({ onClose, onSaved }: { onClose: () => void; onSave export function Media() { const t = useT(); - const [tab, setTab] = useState<"library" | "tasks" | "models">("library"); + const [tab, setTab] = useState<"library" | "tasks">("library"); const [kind, setKind] = useState(""); const [taskStatus, setTaskStatus] = useState(""); const [assetCursors, setAssetCursors] = useState([]); @@ -485,7 +485,6 @@ export function Media() { [ ["library", t("media.tab.library")], ["tasks", t("media.tab.tasks")], - ["models", t("media.tab.models")], ] as const ).map(([value, label]) => (