diff --git a/.env.example b/.env.example index 25d3dc4..9fea02d 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 b589aab..3305bba 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -18,6 +18,7 @@ from . import auth_oauth from . import perf_metrics +from . import media_store as media_store_module 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 +744,151 @@ async def logs_clear(request): return error_response(503, "日志清理未完成,请检查审计存储状态") return JSONResponse(result) + def media_store(): + store = config.get("media_store") + if store is None: + 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 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 "媒体操作不符合当前状态,请刷新后重试") + except ValueError: + return error_response(400, "媒体请求参数无效,请检查 kind/status/limit/cursor") + except Exception: + return error_response(500, "管理操作失败,请检查存储状态后重试") + 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: + 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(ttl_hours=gateway._media_ttl_hours()) if store else { + "total_bytes": 0, "images": {"count": 0, "bytes": 0}, + "videos": {"count": 0, "bytes": 0}}, + "limit_bytes": gateway._media_max_bytes(), + "ttl_hours": gateway._media_ttl_hours(), + "tasks": store.task_counts() if store else {"active": 0, "failed": 0}, + "available": store is not None, + }) + return await run_in_threadpool(build) + + def media_asset_row(item, base): + entry = dict(item) + entry["created_at"] = item["created"] + entry["expires_at"] = item["created"] + gateway._media_ttl_hours() * 3600 + if base: + entry["public_url"] = f"{base}/v1/media/{'videos' if item['kind'] == 'video' else 'images'}/{item['id']}" + return entry + + def live_asset(asset_id): + """Return an asset still inside the current TTL; expired ones vanish before the sweep.""" + meta = media_store().get_asset(asset_id) + if meta is not None and media_store_module.is_expired(meta, gateway._media_ttl_hours()): + return None + return meta + + @media_route("GET", "/admin/media/assets") + async def media_assets(request): + kind, cursor, limit = media_page_params(request) + + def build(): + page = media_store().list_assets(kind, cursor, limit, ttl_hours=gateway._media_ttl_hours()) + base = str(config.get("media_public_base_url") or "") + items = [media_asset_row(item, base) for item in page["items"]] + return JSONResponse({**page, "items": items}) + return await run_in_threadpool(build) + + @media_route("GET", "/admin/media/assets/{asset_id}") + async def media_asset_meta(request): + def build(): + meta = live_asset(request.path_params["asset_id"]) + if meta is None: + return error_response(404, "媒体资源不存在或已清理") + return JSONResponse(media_asset_row(meta, str(config.get("media_public_base_url") or ""))) + return await run_in_threadpool(build) + + @media_route("GET", "/admin/media/assets/{asset_id}/content") + async def media_asset_content(request): + def build(): + meta = live_asset(request.path_params["asset_id"]) + file = media_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) + + @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().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) + + @media_route("GET", "/admin/media/tasks") + async def media_tasks(request): + status, cursor, limit = media_page_params(request, status=True) + + def build(): + 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) + + @media_route("POST", "/admin/media/tasks/{task_id}/refresh") + async def media_task_refresh(request): + task_id = request.path_params["task_id"] + task = await gateway._media_video_refresh(task_id, gateway._request_id()) + event("media.task_refreshed", {"task": task_id}) + 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): # A degraded store still reports its own health; a 503 here would hide diff --git a/app/catalog_index.py b/app/catalog_index.py index 3c8486e..024af9a 100644 --- a/app/catalog_index.py +++ b/app/catalog_index.py @@ -1,6 +1,7 @@ """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 @@ -31,6 +32,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 +46,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/gateway_management.py b/app/gateway_management.py index 7120ea6..9f48809 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -352,6 +352,9 @@ def admin_model_inventory(self): facts.setdefault(row["id"], row) for source in model_policy.snapshot(self.CONFIG)["models"]: facts.setdefault(source, {"id": source, "credits": None, "credits_by_profile": {}}) + for media in self.gateway.media_model_details(): + row = facts.get(media["id"]) + facts[media["id"]] = {**media, **(row or {})} result = [] for source, row in facts.items(): rule = model_policy.rule_for(self.CONFIG, source) @@ -390,12 +393,16 @@ def admin_model_preview(self, source, rule): accepted, rejected = [], [] if pool is None: return {"candidates": [], "excluded": []} + media = bool(self.gateway._media_all_kinds(upstream)) + policy = model_policy.view(self.CONFIG) with pool._lock: for entry in pool.entries(): identity = entry.get("account_key") profile = entry.get("profile") reason = None - if not model_policy.credential_enabled(self.CONFIG, entry): + if media: + reason = self.gateway._media_account_reason(entry, upstream, rule=rule, policy=policy) + elif not model_policy.credential_enabled(self.CONFIG, entry): reason = "人工停用" elif not rule.get("enabled", True): reason = "模型已停用" @@ -439,7 +446,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..6a31efd --- /dev/null +++ b/app/media_generation.py @@ -0,0 +1,277 @@ +"""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 + +import re +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" + +MAX_IMAGE_ITEMS = 4 # Public n bound; the upstream contract allows 1..4. +MAX_PROMPT_CHARS = 32000 # Shared by image and video prompts. +MAX_IMAGE_REF_CHARS = 20 * 1024 * 1024 # OpenAI's image_url maxLength; data: URIs run long. +DEFAULT_IMAGE_SIZE = "1024x1024" +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") + +# 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): + 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 exact media tags.""" + tags = item.get("tags") if isinstance(item, dict) else None + if not isinstance(tags, list): + return () + kinds = [] + 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) + + +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("/") + + +# C0 controls except \t \n \r; a compiled regex scans a 20 MiB data URI without a Python-level loop. +_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") + + +def has_control_chars(value: str) -> bool: + """Report C0 control characters other than tab, newline and carriage return.""" + return _CONTROL_CHARS.search(value) is not None + + +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 has_control_chars(value): + raise ValueError(f"{field} 长度或字符无效") + return value.strip() + + +def _clean_prompt(value, field: str) -> str: + """Accept a prompt as-is: not blank, within the shared bound, no odd control chars.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} 必须是非空字符串") + if len(value) > MAX_PROMPT_CHARS or has_control_chars(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 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: # 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) + if images is not None: + if not isinstance(images, (list, tuple)) or not images: + raise ValueError("images 必须是非空数组") + body["image"] = [_clean_text(item, "image_url", MAX_IMAGE_REF_CHARS) 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_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 必须是整数") + 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: + if type(enable_audio) is not bool: + raise ValueError("enable_audio 必须是布尔值") + 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", MAX_IMAGE_REF_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 {} + + +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 对象") + 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: + 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 对象") + 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") + 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. + + 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 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)): + 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..c45801e --- /dev/null +++ b/app/media_store.py @@ -0,0 +1,390 @@ +"""Local media assets and video task metadata in one sqlite-backed store. + +Files live under ``media/images|videos//.`` and are committed +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 + +import hashlib +import json +import os +import secrets +import sqlite3 +import threading +import time +from pathlib import Path + +DB_NAME = "media.sqlite3" +SCHEMA_VERSION = 2 # v2 keeps the submitted public model name on video tasks. +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 200 +INCOMING_DIR = ".incoming" + +# 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_"} + +# 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.""" + 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 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", + "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") + 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", "public_model") + 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.directory / INCOMING_DIR).mkdir(exist_ok=True) + self.sweep_incoming() + 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); + 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, public_model TEXT); + CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created); + """) + + def _migrate_schema(self): + """Apply additive task metadata and remove the obsolete asset expiry column.""" + version = self._db.execute("PRAGMA user_version").fetchone()[0] + if version >= SCHEMA_VERSION: + return + if version < 1: + 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") + columns = {row[1] for row in self._db.execute("PRAGMA table_info(tasks)").fetchall()} + if "public_model" not in columns: + self._db.execute("ALTER TABLE tasks ADD COLUMN public_model TEXT") + self._db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + 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 _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, 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 + 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) + 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: + 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_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: + 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,)) + file = self.asset_file(meta) + if file is not None: + 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: + """Best-effort: this runs after the metadata commit and must not fail a committed change.""" + for file in paths: + try: + file.unlink(missing_ok=True) + except OSError: + pass + + def sweep_incoming(self, max_age_seconds: float = 3600) -> None: + """Drop staged payloads that a crashed save left behind; fresh ones may still be in flight.""" + cutoff = time.time() - max_age_seconds + try: + staged = list((self.directory / INCOMING_DIR).iterdir()) + except OSError: + return + for file in staged: + try: + if file.stat().st_mtime < cutoff: + file.unlink(missing_ok=True) + except OSError: + pass + + def list_assets(self, kind=None, cursor=None, limit=DEFAULT_PAGE_SIZE, *, + ttl_hours: float) -> dict: + """List live assets newest first; rows past the current TTL stay hidden until swept.""" + limit = max(1, min(int(limit), MAX_PAGE_SIZE)) + clauses, params = ["created>?"], [time.time() - float(ttl_hours) * 3600] + 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, *, ttl_hours: float) -> dict: + """Count live assets only, matching what reads still serve.""" + with self._lock: + rows = self._db.execute( + "SELECT kind, COUNT(*), COALESCE(SUM(size),0) FROM assets WHERE created>? " + "GROUP BY kind", (time.time() - float(ttl_hours) * 3600,)).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_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.""" + with self._lock: + 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) + self.sweep_incoming() + 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, + public_model=None) -> dict | None: + with self._lock: + now = time.time() + self._db.execute( + "INSERT OR IGNORE INTO tasks(task_id,account_key,profile,model,created,updated," + "status,media_id,error,credit,public_model) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (task_id, account_key, profile, model, now, now, "queued", None, None, None, + public_model or model)) + 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=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: + 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"], + current["error"] if error is UNSET else 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/model_policy.py b/app/model_policy.py index 3c149ab..e21bee8 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -202,31 +202,51 @@ def public_name(fallback): def public_details(gateway, region=None): - """Publish each route using only its upstream capabilities and allowed accounts.""" + """Publish chat and media routes using their own account eligibility checks.""" config = gateway.CONFIG - raw = gateway.current_model_details(region) - if config.get("control_store") is None: - return raw - known = {item["id"]: item for item in raw} - policies = snapshot(config)["models"] + known = {item["id"]: item for item in gateway.current_model_details(region)} + for media in gateway.media_model_details(): + row = known.get(media["id"]) + known[media["id"]] = {**media, **(row or {})} + policy = view(config) + policies = policy.data["models"] owners = {rule.get("public_id", source): source for source, rule in policies.items()} pool = config.get("cred_pool") entries = pool.entries() if pool is not None else [] out = [] for source in dict.fromkeys([*known, *policies]): - rule = rule_for(config, source) + rule = rule_for(config, source, policy) upstream = rule["upstream_id"] item = known.get(upstream) if not rule["enabled"] or item is None: continue + media = bool(item.get("media_kinds")) token = _request_policy.set({"source": source, "upstream": upstream, "public_model": rule["public_id"], "rule": rule}) try: - candidates = [entry for entry in entries if route_allowed(config, entry, upstream, rule=rule) - and pool._eligible(entry, upstream, region=region)] + if media: + candidates = [entry for entry in entries if gateway._media_account_reason( + entry, upstream, rule=rule, policy=policy, region=region) is None] + if pool is None: + cm = config.get("cred") + candidates = [] + if cm is not None: + with cm._lock: + headers = cm.get_headers() + profile = gateway.profile_for_headers(headers) + identity = gateway.account_key(profile, headers.get("X-User-Id"), + headers.get("X-Enterprise-Id")) + entry = {"profile": profile, "account_key": identity} + if (profile in gateway.media_generation.MEDIA_PROFILES + and route_allowed(config, entry, upstream, rule=rule, policy=policy)): + candidates = [entry] + else: + candidates = [entry for entry in entries if route_allowed( + config, entry, upstream, rule=rule, policy=policy) + and pool._eligible(entry, upstream, region=region)] finally: _request_policy.reset(token) - if pool is not None and not candidates: + if not candidates and (pool is not None or media): continue prices, measured = {}, set() accounts = config.get("account_catalogs") @@ -257,6 +277,9 @@ def public_details(gateway, region=None): best = min(prices.values(), default=None) row = {"id": name, "credits": best, "credits_by_profile": prices, **route_metadata(gateway, upstream, entries=candidates, rule=rule, region=region)} + for key in ("media_kinds", "media_sources", "media_defaults"): + if key in item: + row[key] = item[key] if any(prices[profile] == best for profile in measured): row["credits_estimated"] = True # Upstream declares it free but charges it. out.append(row) diff --git a/app/request_context.py b/app/request_context.py index d74ad63..f54c2b0 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 bca1cbd..49a9085 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.""" @@ -163,6 +164,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), + # 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), # 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), 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 0a40159..9dff131 100644 --- a/converter.py +++ b/converter.py @@ -2,9 +2,11 @@ """Expose CodeBuddy and WorkBuddy through compatible Chat, Responses and Messages APIs.""" from __future__ import annotations +import base64 import argparse import asyncio +import weakref import hashlib import itertools import json @@ -30,7 +32,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. @@ -105,8 +107,8 @@ def apply_system_prompt_mode(body, profile, mode): 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, @@ -2738,6 +2740,16 @@ 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=_media_ttl_hours(), + max_total_bytes=_media_max_bytes()) + 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) failed = set() try: @@ -2943,6 +2955,15 @@ def _check_admin_auth(authorization: Optional[str], x_api_key: Optional[str]): _check_auth(authorization, x_api_key) +def _apply_session_headers(headers: dict, profile, skey: str | None, context) -> None: + """Add the conversation and trace headers every upstream call carries.""" + identity = account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id")) + if context is not None and context.scoped: + headers["X-Conversation-ID"] = context.conversation_id(profile, identity) + else: + headers.update(_dynamic_request_headers(f"{profile}:{identity}:{skey}" if skey else None, profile=profile)) + + def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=(), requirements=None, sticky=True): """Select a fresh credential lease and headers, excluding tried accounts; report unavailable capacity. @@ -2995,11 +3016,7 @@ def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=(), profile = profile_for_headers(headers) if not _in_region(profile, region): raise HTTPException(status_code=503, detail={"error": {"message": "未找到指定地域凭据", "type": "auth_error"}}) - identity = account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id")) - if context is not None and context.scoped: - headers["X-Conversation-ID"] = context.conversation_id(profile, identity) - else: - headers.update(_dynamic_request_headers(f"{profile}:{identity}:{skey}" if skey else None, profile=profile)) + _apply_session_headers(headers, profile, skey, context) return cm, headers @@ -3957,6 +3974,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) @@ -3979,7 +3997,8 @@ def list_models(authorization: Optional[str] = Header(default=None), _check_auth(authorization, x_api_key) data = [{"id": item["id"], "object": "model", "created": 1700000000, "owned_by": "codebuddy", "credits": item["credits"], "credits_by_profile": item["credits_by_profile"], - **{key: item[key] for key in ("credits_estimated", "capabilities", "limits", "metadata_by_profile") + **{key: item[key] for key in ("credits_estimated", "capabilities", "limits", "metadata_by_profile", + "media_kinds", "media_sources", "media_defaults") if key in item}} for item in model_policy.public_details(sys.modules[__name__])] return {"object": "list", "data": data} @@ -5098,6 +5117,568 @@ 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): + """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.""" + 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) +_MEDIA_ROUTE_REFUSED = re.compile(r"route config not found|model .{0,80}(?:not found|not supported|not served)", re.I) + + +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: + """Media kinds declared for this model in account root tables (serves scope).""" + index = _catalog_index() + return list(index.media_kinds.get(model, ())) if index is not None else [] + + +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_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"}}) + + +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: + """Static models defer to upstream; dynamic models require account catalog membership.""" + if media_generation.model_kinds(model): + return True + items = account.get("serves") if isinstance(account, dict) else None + if items is None: + return False + return any(isinstance(item, dict) and item.get("id") == model for item in items) + + +def _media_account_reason(entry, model, *, rule=None, policy=None, region=None): + """Return the media-specific reason an account cannot serve a model, or None.""" + pool = CONFIG.get("cred_pool") + profile = CredentialPool._entry_profile(entry) + if not model_policy.credential_enabled(CONFIG, entry, policy): + return "人工停用" + rule = rule or model_policy.active_rule(CONFIG, model, policy) + if not rule.get("enabled", True): + return "模型已停用" + if not model_policy.route_allowed(CONFIG, entry, model, rule=rule, policy=policy): + return "不在绑定范围" + if not profile or profile not in media_generation.MEDIA_PROFILES: + return "产品不支持媒体生成" + if region and not _in_region(profile, region): + return "不在区域范围" + if not pool._healthy(entry, policy): + return "认证熔断" + account = (CONFIG.get("account_catalogs") or {}).get(entry.get("account_key")) + if not _media_account_serves(account, model): + return "账号自身目录不支持模型" + if not pool._has_credit(entry, profile): + return "额度不足或未知" + return None + +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() + profile = profile_for_headers(headers) + if profile not in media_generation.MEDIA_PROFILES: + raise HTTPException(status_code=503, detail={"error": { + "message": "当前凭证产品不支持媒体生成", "type": "auth_error"}}) + identity = account_key(profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id")) + entry = {"profile": profile, "account_key": identity} + if not model_policy.route_allowed(CONFIG, entry, model): + raise HTTPException(status_code=503, detail={"error": { + "message": "当前凭证不在媒体模型路由范围内", "type": "service_unavailable", + "code": "no_media_account"}}) + _apply_session_headers(headers, profile, None, current_context()) + return cm, headers + policy = model_policy.view(CONFIG) + excluded = {_cred_manager(item) for item in tried} + with pool._lock: + candidates = [] + for entry in pool._entries: + if entry["cm"] in excluded or _media_account_reason(entry, model, policy=policy) is not None: + continue + candidates.append(entry) + 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 + profile = profile_for_headers(headers) + if profile 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) + _apply_session_headers(headers, profile, None, current_context()) + 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"}}) + else: + 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"}}) + cm = entry["cm"] + with cm._lock: + headers = cm.get_headers() + _apply_session_headers(headers, profile_for_headers(headers), None, current_context()) + return cm, headers + + +def _media_replayable(error) -> bool: + """Replay only when the body was never accepted or upstream explicitly refused.""" + if isinstance(error, UpstreamHTTPError): + # Restricted and model-scoped refusals would fail the same way on another account. + code = _upstream_diagnostic(error.raw)[0] + if error.status in (400, 404): + raw = error.raw.decode("utf-8", "replace") if error.raw else "" + return (_parse_not_servable(error.raw, error.status) is not None + or bool(_MEDIA_ROUTE_REFUSED.search(raw))) + return error.status in (401, 403, 429) and code not in NO_FAILOVER_UPSTREAM_CODES + return isinstance(error, REPLAYABLE_TRANSPORT) + + +def _media_note_restriction(cred, code, body=None) -> bool: + """Disable an account the upstream reports as restricted; other refusals leave its health alone.""" + pool = CONFIG.get("cred_pool") + if pool is None: + return False + cm, generation = cred if isinstance(cred, tuple) else (cred, None) + return pool.note_restriction(cm, code, body=body, generation=generation) + + +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_policy.public_name(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, error_body = _upstream_diagnostic(raw) + observe_attempt("upstream_error", status_code=response.status_code, + upstream_code=code, upstream_msg=message) + # Health isolation: only a restriction code or 401 reaches the cooldown/refresh path. + if not _media_note_restriction(cred, code, error_body) and 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 media_generation.MediaUpstreamError as error: + # A business refusal may already be billed; never replay it on another account. + observe_failure("media_upstream_refused") + _media_note_restriction(cred, error.upstream_code, _upstream_diagnostic(raw)[2]) + 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": { + "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: + if response.status_code in (403, 404, 410): # Signature expired or gone. + raise MediaDownloadError( + f"资源下载失败(HTTP {response.status_code},签名可能已过期)") + raise MediaDownloadRetryable(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.InvalidURL: # Not an HTTPError, and a malformed URL will not fix itself. + raise MediaDownloadError("资源地址无效") from None + except httpx.HTTPError as error: + raise MediaDownloadRetryable(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: + # A full disk or locked database may clear up; do not fail a paid video for it. + raise MediaDownloadRetryable(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. + + 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() + + async def load(): + task = await run_in_threadpool(store.get_task, task_id) + if task is None: # Also covers a task swept while this refresh was running. + raise HTTPException(status_code=404, detail={"error": { + "message": "视频任务不存在或已过期", "type": "not_found_error", "code": "task_not_found"}}) + return task + + task = await load() + if task["status"] in ("completed", "failed"): + return task # Repeated queries keep returning the same resource or error. + async with _media_task_lock(task_id): + task = await load() + if task["status"] in ("completed", "failed"): + return task + cred, headers = await run_in_threadpool(_media_bound_cred, task["account_key"]) + try: + 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) + except HTTPException as error: + detail = error.detail.get("error") if isinstance(error.detail, dict) else None + message = detail.get("message") if isinstance(detail, dict) else None + message = sanitize_log_text(str(message or "视频任务轮询失败"), 500) + try: + await run_in_threadpool(store.update_task, task_id, status="failed", error=message) + except Exception as store_error: + _log(f"[{rid}] ✗ 视频任务失败状态写入失败 | {task_id} | " + f"{type(store_error).__name__}") + raise + 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.get("public_model") or task["model"], "created_at": int(task["created"])} + if task["status"] == "completed": + 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 and not expired: + 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 "视频生成失败"} + elif task["error"]: + body["message"] = task["error"] # A retryable download hint. + 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 = 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 media_generation.has_control_chars(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, 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) + if _media_base_url(): + return {"url": _media_public_url(meta), **revised}, None + return {"b64_json": item["b64_json"], **revised}, None + data = await _media_download_bytes(item["url"], rid) + if persist: + meta = await _media_save("image", data, model=model, rid=rid) + if _media_base_url(): + return {"url": _media_public_url(meta), **revised}, None + return {"b64_json": base64.b64encode(data).decode("ascii"), **revised}, None + except (MediaDownloadError, MediaDownloadRetryable, ValueError) as error: + return None, str(error) + + +async def _media_image_reply(items: list, model, rid: str, *, persist: bool) -> list: + """Download all image items concurrently; keep every success and fail only when none survive.""" + selected = items[:media_generation.MAX_IMAGE_ITEMS] + results = await asyncio.gather(*(_media_image_item(item, model, rid, persist=persist) + for item in selected), return_exceptions=True) + out, failures, first_error = [], 0, "" + for result in results: + if isinstance(result, BaseException): + # Anything unexpected still counts as one failed item; the others keep their results. + payload, error = None, f"处理异常:{type(result).__name__}" + _log(f"[{rid}] ✗ 图片条目处理异常 | {type(result).__name__}") + else: + payload, error = result + if error is None: + out.append(payload) + else: + failures += 1 + first_error = first_error or error + if failures == len(selected): + raise HTTPException(status_code=502, detail={"error": { + "message": f"全部 {len(selected)} 张图片下载或保存失败:{first_error}", + "type": "upstream_error"}}) + if failures: + _log(f"[{rid}] ✗ 图片部分失败 {failures}/{len(selected)} | {first_error}") + observe_failure("image_item_failed") + return out + + +def media_model_details() -> list: + """Return static and catalog media models in the shared catalog shape.""" + defaults = {"image": media_generation.DEFAULT_IMAGE_MODEL, + "edit": media_generation.DEFAULT_IMAGE_EDIT_MODEL, + "video": media_generation.DEFAULT_VIDEO_MODEL} + 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: + static_kinds.setdefault(model, []).append(kind) + index = _catalog_index() + catalog_kinds_map = index.media_kinds if index is not None else {} + 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 + entry = table.setdefault(model, {"kinds": (), "catalog": False}) + entry["catalog"] = True + entry["kinds"] = tuple(dict.fromkeys(entry["kinds"] + kinds)) + return [{"id": model, "credits": None, "credits_by_profile": {}, + "media_kinds": list(entry["kinds"]), + "media_sources": (["static"] if model in static_kinds else []) + + (["catalog"] if entry["catalog"] else []), + "media_defaults": [kind for kind, name in defaults.items() if name == 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): @@ -5528,6 +6109,194 @@ 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) + requested_model = payload.get("model") or media_generation.DEFAULT_IMAGE_MODEL + model = model_policy.resolve(CONFIG, requested_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) + 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() + persist = payload.get("response_format") != "b64_json" + if persist: + _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) + 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) + requested_model = payload.get("model") or media_generation.DEFAULT_IMAGE_EDIT_MODEL + model = model_policy.resolve(CONFIG, requested_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) + 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() + persist = payload.get("response_format") != "b64_json" + if persist: + _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) + 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) + requested_model = payload.get("model") or media_generation.DEFAULT_VIDEO_MODEL + model = model_policy.resolve(CONFIG, requested_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", + media_generation.MAX_IMAGE_REF_CHARS)) + 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) + task = await run_in_threadpool(store.create_task, task_id, note.get("credential"), + note.get("profile"), model, public_model=requested_model) + return {"id": task_id, "object": "video", "status": "queued", "model": requested_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()) + 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") +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 = 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": "视频资源尚不可用(任务未完成或资源已清理)", + "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"}}) + 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"}}) + 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..f905faa 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -99,6 +99,18 @@ 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"?}`. 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. Image references (including the video `image_url`) accept up to 20 MiB of text, so `data:` URIs fit. +- `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`. +- Images are stored locally by default. Without the asset base URL (`media_public_base_url`, WebUI **Media**) they return `b64_json` without a public link; an explicit `response_format=b64_json` skips image persistence. Videos are always stored locally. Setting the base URL enables `url` responses and video links; assets are served 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). Media models share `/v1/models` and WebUI **Model routing**, including aliases, enable switches and binding rules; chat endpoints reject them with a pointer here. +- Accounts are picked per request from verified media-capable products only (currently `cn-cli`) and require a positive balance. Static media models defer authorization to upstream even when absent from an account catalog; dynamic models require exact media catalog tags. A refused request (401/403/429) may be retried once on another allowed account per the failover setting, but upstream restriction codes are never replayed and account restrictions disable the account (matching chat behavior); 5xx and timeouts are never replayed to avoid double billing. Video task polling stays bound to the submitting account. +- 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 fail permanently only when the signature is gone (403/404/410), the type is refused or the file is over quota; repeated queries then return the same error. Any other failure (network, timeout, 408/429/5xx, local storage errors) keeps the task active, shows a `message` retry hint on the in-progress task, and the next query retries. + ## 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..02d8689 100644 --- a/docs/clients.zh-CN.md +++ b/docs/clients.zh-CN.md @@ -99,6 +99,18 @@ 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"?}`。prompt 原样下发,支持多行(≤32000 字符);gemini 族忽略 `n` 与 `quality`,与官方 CLI 一致。 +- `POST /v1/images/edits`:OpenAI 风格 JSON:`{"prompt", "images": [{"image_url": "https://… | data:…"}], "model?", "size?", "input_fidelity?"}`;不支持 multipart。图片引用(含视频 `image_url`)最长 20 MiB 文本,可直接传 `data:` URI。 +- `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`,不生成公开链接;显式 `response_format=b64_json` 则不保存图片。视频始终落盘。配置地址后可返回 `url` 和视频链接;资源经 `/v1/media/{images|videos}/{id}` 公开读取,带不可变缓存头。 +- 默认模型按族固定:文生图 `hunyuan-image-v3.0`、图生图 `hunyuan-image-v2.0-general-edit`、视频 `minimax-video-h3`。媒体模型统一进入 `/v1/models` 和 WebUI「模型路由」,共用别名、启停与绑定规则;媒体模型打到聊天端点会被拒绝并指引到本节。 +- 按请求选号,仅限已实测可用的媒体产品(当前为 `cn-cli`),且余额为正。静态媒体模型即使不在账号目录中也由上游裁决权限;动态模型要求目录带对应媒体标签。被明确拒绝(401/403/429)时可按 failover 配置在允许范围内换号重试一次,但上游限制码不会重放,账号限制码会禁用该账号(与聊天接口一致),5xx 与超时一律不重放以避免重复扣费。视频任务始终绑定提交账号查询。 +- 多张图片并发下载,部分失败时返回成功的条目;全部失败才返回 502 且不残留已落盘文件。视频下载仅在签名失效(403/404/410)、类型被拒或超容量时永久失败,重复查询返回同一错误;其余失败(网络、超时、408/429/5xx、本地存储错误)任务保持进行中,进行中响应带 `message` 重试提示,下次查询重试。 + ## 值得了解的协议行为 - 先将 `developer` 转为 `system` 并置顶首条系统消息,再关联工具结果;不改动调用方原始载荷。 diff --git a/docs/development.md b/docs/development.md index 2f35b4a..ab817ce 100644 --- a/docs/development.md +++ b/docs/development.md @@ -38,6 +38,7 @@ Everything below the gateway is simulated; everything in it is the real code. Re | Balances | Credit packages with different expiries; requests consume them, check-ins, trips, tasks and trial credits add to them | | Models | Per-region catalogs with rates, capabilities and limits | | Inference | Chat Completions, Responses and Anthropic Messages stream a simulated reply that quotes your message, with reasoning where the model supports it | +| Media | Seeded images and videos plus simulated generation; public WebUI media and model mutations are browser-local and reset on reload | | Automation | Check-in, Buddy travel (a trip takes three minutes), growth tasks, trial credits and the international active check-in | | Sign-in | Adding an account signs in a new synthetic account by itself after four seconds; the official login link it shows never needs to be opened | | Search, fetch and MCP | `/v1/search`, `/v1/fetch` and the MCP `web_search` / `web_fetch` tools return simulated results on `example.com` | @@ -59,7 +60,7 @@ Put a directive anywhere in the last user message to steer the simulated model: ## Public deployment -`--public` makes the preview safe to share. It refuses credential upload, import, export and deletion, settings changes, log clearing, archiving and update actions, so nobody can leave real credentials for the next visitor to export or spoil the shared demo. `--reset-hours` exits after the given time so the process manager starts over with fresh data. Options the preview does not know pass through to the gateway, such as the trusted management origin a reverse proxy needs. +`--public` makes the preview safe to share. Direct management writes for media deletion and task refresh, model rules and settings are refused alongside credential transfers, log clearing, archiving and updates. In the WebUI, media deletion and task refresh, media settings, model rules and model guards run in a per-tab in-memory overlay: the browser shows the completed operation, the server keeps its initial state, and a page reload restores that state. `--reset-hours` exits after the given time so the process manager starts over with fresh data. Options the preview does not know pass through to the gateway, such as the trusted management origin a reverse proxy needs. The published `edge` image already contains the gateway and the WebUI, so mounting this directory is enough: diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index 7ce3ec3..45edf28 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -37,6 +37,7 @@ CODEBUDDY_WEBUI_PROXY=http://127.0.0.1:8790 vp dev # http://127.0.0.1:5174 | 历史 | 一个月的请求记录,概览、日志和统计打开即有数据 | | 余额 | 不同到期时间的积分包;请求会消耗,签到、旅行、任务和体验积分会增加 | | 模型 | 按区域区分的模型目录,包含倍率、能力和上限 | +| 媒体 | 预置图片和视频,支持模拟生成;公开 WebUI 的媒体与模型修改仅保留在浏览器,刷新复位 | | 推理 | Chat Completions、Responses 和 Anthropic Messages 都会流式返回引用你消息的模拟回复,支持推理的模型带推理内容 | | 自动化 | 签到、Buddy 旅行(一趟 3 分钟)、成长任务、体验积分和国际站活跃打卡 | | 登录 | 添加账号会在 4 秒后自动登录一个新的合成账号;界面上显示的官方登录链接无需打开 | @@ -59,7 +60,7 @@ CODEBUDDY_WEBUI_PROXY=http://127.0.0.1:8790 vp dev # http://127.0.0.1:5174 ## 公开部署 -`--public` 让预览服务可以公开分享。它会拒绝上传、导入、导出和删除凭证,也会拒绝修改设置、清空日志、归档和更新操作,这样访客既不会留下真实凭证被下一位访客导出,也不会把共享的演示弄坏。`--reset-hours` 会在指定时间后退出,由进程管理器用全新数据重新启动。预览服务不认识的参数会原样传给网关,例如反向代理需要的受信任管理来源。 +`--public` 让预览服务可以公开分享。直接删除媒体、刷新视频任务、修改模型规则或设置的写请求会被拒绝,凭证导入导出、日志清理、归档和更新也仍受限制。WebUI 中的媒体删除、视频任务刷新、媒体设置、模型规则和模型开关则在当前标签页的内存中完成:浏览器能看到完整操作结果,服务器保持初始状态,刷新页面即恢复。`--reset-hours` 会在指定时间后退出,由进程管理器用全新数据重新启动。预览服务不认识的参数会原样传给网关,例如反向代理需要的受信任管理来源。 发布的 `edge` 镜像已包含网关和 WebUI,只需挂载本目录: diff --git a/docs/webui.md b/docs/webui.md index bc4bf3d..be66caf 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -39,6 +39,7 @@ Reward claims and departures are durably reserved before sending. Stale or faile Add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. - **Model details** shows descriptions, capability states, token limits, reasoning options and safe raw metadata by product; differences remain separate. Declarations are read-only and do not guarantee native or measured support. Route previews use the draft binding scope. +- Media models share this catalog and routing rules; badges distinguish text-to-image, image editing and video models, with matching capability filters. - International catalogs are shared and deduplicated. Inherited entries show "Shared catalog source" with expandable safe originals; native declarations and shared references remain distinguishable. - The rate column shows `≈0.037x · 实测计费` for a model declared free that upstream charges, with the sample counts on hover, and `· 手填` for a manual rate. **Edit rule → 消费倍率** takes a manual rate per product and **一键探测倍率** sends one ≈8K-token request per product to tell real 0x from measured billing; **一键检测倍率** in the header does this for every model declared free. See [Measured rates and output limits](advanced.md#measured-rates-and-output-limits). - The max output column overlays results from `scripts/probe_max_tokens.py`: red `32K ⚠ 钳制 12×` for silent clamping, green `48K ✓` / `64K ↑` when the claim holds or is exceeded, grey `≥40K` for a lower bound and `?` when nothing was measured. @@ -49,6 +50,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 confirmation dialog. **Video tasks** refresh in-flight tasks on their bound account and link finished videos back to the library. Model catalogs and routing are managed under **Model routing**. +- Images are stored locally by default; without an asset base URL they return base64 without a public link. Explicit `response_format=b64_json` skips image persistence. Videos are always stored locally, and links appear 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). 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 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 +115,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..dbb8602 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -39,6 +39,7 @@ 添加独立映射,设置对外/上游 ID 与本地启停。绑定范围二选一:指定账号,或地域加可选产品过滤;切换模式会清除另一种绑定。候选不可用时绝不会回退到范围外账号。 - 「模型详情」按产品展示描述、能力状态、token 上限、推理选项与安全的原始元数据;有差异的字段分别保留。声明为只读参考,不构成原生能力或实测保证。路由预览使用草稿中的绑定范围。 +- 媒体模型共用此目录与路由规则,以徽标区分文生图、图生图和视频模型,并可按这些类型筛选。 - 国际目录共享并去重。继承的条目显示「共享目录来源」,可展开查看安全的原始声明;原生声明与共享引用始终可区分。 - 倍率列对「声明免费但上游实际扣费」的模型显示 `≈0.037x · 实测计费`,悬浮查看样本数;手填倍率显示 `· 手填`。「编辑规则 → 消费倍率」可按产品手填倍率,「一键探测倍率」在每个产品上发起一次约 8K token 的请求,区分真 0 倍率与实测计费;页头「一键检测倍率」对所有声明免费的模型执行同样探测。详见[实测计费与输出上限](advanced.zh-CN.md#实测计费与输出上限)。 - 最大输出列叠加 `scripts/probe_max_tokens.py` 的实测结果:红色 `32K ⚠ 钳制 12×` 表示静默钳制,绿色 `48K ✓` / `64K ↑` 表示与声称一致或更大,灰色 `≥40K` 为下界,`?` 为未测出。 @@ -49,6 +50,14 @@ 「清空全部日志与统计」不可恢复:输入对话框中的确认文字,并重新输入当前 API key。此操作不删除凭证与网关配置。 +## 媒体生成 + +「媒体生成」页展示本地保存的生成结果:本地占用与容量上限、图片与视频数量、进行中与失败的视频任务。 + +- **资源库**按时间倒序列出资源,含模型、大小与剩余保留时长;卡片打开预览抽屉,可查看元数据、下载与删除确认弹窗。**视频任务**用绑定账号刷新进行中的任务,并把完成的视频链回资源库。模型目录与路由统一在「模型路由」页管理。 +- 图片默认保存到本地;未配置资源访问地址时返回 base64,不生成公开链接。显式 `response_format=b64_json` 不保存图片。视频始终保存到本地,配置地址后即可获得链接。 +- **媒体设置**(`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 +115,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/public.py b/preview/public.py index b32f309..9b307c3 100644 --- a/preview/public.py +++ b/preview/public.py @@ -4,6 +4,7 @@ import threading from starlette.responses import JSONResponse +from starlette.datastructures import MutableHeaders MESSAGE = "公开预览中不可用此操作" # Imports would let a visitor leave real credentials that the next visitor could export; the @@ -13,6 +14,11 @@ ("DELETE", r"/admin/credentials/[^/]+"), ("PATCH", r"/admin/settings"), ("POST", r"/admin/logs/clear"), + ("DELETE", r"/admin/media/assets/[^/]+"), + ("POST", r"/admin/media/tasks/[^/]+/refresh"), + ("POST", r"/admin/models"), + ("PUT", r"/admin/models/.+"), + ("DELETE", r"/admin/models/.+"), ("POST", r"/admin/storage/archive"), ("POST", r"/admin/storage/backups"), ("DELETE", r"/admin/storage/backups/[^/]+"), @@ -31,12 +37,16 @@ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): + async def marked_send(message): + if message["type"] == "http.response.start": + MutableHeaders(scope=message)["X-CodeBuddy-Preview"] = "public" + await send(message) + if scope["type"] == "http" and blocked(scope["method"], scope["path"]): response = JSONResponse({"error": {"message": MESSAGE, "type": "admin_error"}}, status_code=403) - await response(scope, receive, send) + await response(scope, receive, marked_send) return - await self.app(scope, receive, send) - + await self.app(scope, receive, marked_send) def schedule_reset(config, hours: float, *, timer=threading.Timer): """Stop the server after ``hours`` so the supervisor starts it again with fresh data.""" diff --git a/preview/seed.py b/preview/seed.py index 038d166..9f0c262 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,27 @@ 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=? 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", "") + 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..be50c9f 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,17 @@ 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 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": [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)}] 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 +441,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..cc07fdd --- /dev/null +++ b/tests/test_media_generation.py @@ -0,0 +1,1356 @@ +#!/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 asyncio +import base64 +import concurrent.futures +import json +import os +import sqlite3 +import struct +import tempfile +import threading +import time +import unittest +from types import SimpleNamespace +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, media_store +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") +MEDIA_PATHS = IMAGE_PATHS + ("/v2/videos/generations", "/v2/videos/tasks") + + +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_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": ["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"]}])) + + 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): + # 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}) + 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"], + 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=[]) + # 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_control_character_check(self): + for allowed in ("plain", "tab\tnewline\ncarriage\r", "中文 ~\x7f"): + self.assertFalse(media_generation.has_control_chars(allowed), repr(allowed)) + for rejected in ("\x00", "a\x01b", "tail\x1f", "\x08", "\x0b", "\x0c", "\x0e"): + self.assertTrue(media_generation.has_control_chars(rejected), repr(rejected)) + + def test_blank_prompts_are_rejected_but_text_is_kept_verbatim(self): + for blank in (" ", "\n\t ", " \r\n"): + with self.assertRaises(ValueError, msg=repr(blank)): + media_generation.image_request_body("hunyuan-image-v3.0", blank) + with self.assertRaises(ValueError, msg=repr(blank)): + media_generation.video_request_body("minimax-video-h3", prompt=blank) + padded = " a cat\nwith a hat \n" + self.assertEqual(media_generation.image_request_body("hunyuan-image-v3.0", padded)["prompt"], padded) + self.assertEqual(media_generation.video_request_body("minimax-video-h3", prompt=padded)["prompt"], + padded) + + def test_image_references_have_their_own_bound(self): + uri = "data:image/png;base64," + base64.b64encode(b"\x00" * 30000).decode("ascii") + self.assertGreater(len(uri), media_generation.MAX_PROMPT_CHARS) + body = media_generation.image_request_body( + "hunyuan-image-v2.0-general-edit", "edit", images=[uri]) + self.assertEqual(body["image"], [uri]) + self.assertEqual( + media_generation.video_request_body("minimax-video-h3", image_url=uri)["image_url"], uri) + with patch.object(media_generation, "MAX_IMAGE_REF_CHARS", len(uri) - 1): + with self.assertRaises(ValueError): + media_generation.image_request_body("hunyuan-image-v2.0-general-edit", "edit", images=[uri]) + with self.assertRaises(ValueError): + media_generation.video_request_body("minimax-video-h3", image_url=uri) + with self.assertRaises(ValueError): # Prompts keep the smaller bound. + media_generation.image_request_body("hunyuan-image-v3.0", "x" * len(uri)) + + 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")) + # 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.""" + + 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(ttl_hours=72)["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(ttl_hours=72) + 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) + self.store.create_task("t1", "acct", "cn-cli", "m") + self.store.update_task("t1", status="completed", media_id=meta["id"], credit=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_or_evicted"], 1) + self.assertIsNone(self.store.get_asset(meta["id"])) + self.assertIsNone(self.store.get_task("t1")) + + def test_expired_assets_leave_listing_and_usage_immediately(self): + meta = self.save("image", PNG_BYTES) + self.store._db.execute("UPDATE assets SET created=?", (time.time() - 2 * 3600,)) + self.store._db.commit() + self.assertEqual(len(self.store.list_assets(ttl_hours=72)["items"]), 1) + self.assertEqual(self.store.usage(ttl_hours=72)["images"]["count"], 1) + # Shrinking the TTL hides the row before any sweep, while the file stays for it. + self.assertEqual(self.store.list_assets(ttl_hours=1)["items"], []) + self.assertEqual(self.store.list_assets(kind="image", ttl_hours=1)["items"], []) + usage = self.store.usage(ttl_hours=1) + self.assertEqual(usage["total_bytes"], 0) + self.assertEqual(usage["images"], {"count": 0, "bytes": 0}) + self.assertIsNotNone(self.store.get_asset(meta["id"])) + + def test_sweep_incoming_removes_only_stale_files(self): + incoming = self.store.directory / media_store.INCOMING_DIR + stale, fresh = incoming / "stale.png", incoming / "fresh.png" + for file in (stale, fresh): + file.write_bytes(PNG_BYTES) + old = time.time() - 2 * 3600 + os.utime(stale, (old, old)) + self.store.sweep_incoming() + self.assertFalse(stale.exists()) + self.assertTrue(fresh.exists()) + # Housekeeping and a restart reclaim leftovers too. + os.utime(fresh, (old, old)) + self.store.cleanup(ttl_hours=72, max_total_bytes=1 << 30) + self.assertFalse(fresh.exists()) + stale.write_bytes(PNG_BYTES) + os.utime(stale, (old, old)) + reopened = MediaStore(self.root) + self.addCleanup(reopened.close) + self.assertFalse(stale.exists()) + + def test_unlink_is_best_effort_and_keeps_shard_directories(self): + meta = self.save("image", PNG_BYTES) + shard = self.store.asset_file(meta).parent + self.assertTrue(self.store.delete_asset(meta["id"])) + self.assertTrue(shard.is_dir()) # Empty shards stay: removing them races with new saves. + broken, healthy = Mock(), Mock() + broken.unlink.side_effect = OSError("read-only filesystem") + MediaStore._unlink([broken, healthy]) + healthy.unlink.assert_called_once() + + 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(ttl_hours=72)["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(ttl_hours=72)["images"]["count"], 1) + + def test_incoming_residue_is_not_counted(self): + before = self.store.usage(ttl_hours=72) + stray = self.store.directory / media_store.INCOMING_DIR / "leftover.png" + stray.write_bytes(PNG_BYTES) + self.assertEqual(self.store.usage(ttl_hours=72), 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") + 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, ttl_hours=72) + self.assertEqual(len(page["items"]), 2) + self.assertTrue(page["has_more"]) + second = self.store.list_assets(kind="image", cursor=page["next_cursor"], limit=2, ttl_hours=72) + 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", ttl_hours=72) + with self.assertRaises(ValueError): + self.store.list_tasks(status="bogus") + + + def test_v1_database_adds_public_model_without_losing_tasks(self): + self.store.close() + db = sqlite3.connect(self.store.path) + db.execute("ALTER TABLE tasks DROP COLUMN public_model") + db.execute("INSERT INTO tasks VALUES('task_old','acct','cn-cli','upstream',1,1,'queued',NULL,NULL,NULL)") + db.execute("PRAGMA user_version=1") + db.commit() + db.close() + reopened = MediaStore(self.store.root) + self.addCleanup(reopened.close) + self.assertIsNone(reopened.get_task("task_old")["public_model"]) + created = reopened.create_task("task_new", "acct", "cn-cli", "upstream", public_model="alias") + self.assertEqual(created["model"], "upstream") + self.assertEqual(created["public_model"], "alias") + + +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.image_envelope_error = False # Image replies become business-error envelopes. + self.image_urls = None # Explicit COS urls returned per image item. + self.media_replies = [] # (status, json) answers for the next media requests. + 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": "", + "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 install_policy(self): + store = ControlStore(self.root / f"media-policy-{self.fixture_sequence}.sqlite3") + self.addCleanup(store.close) + self.enterContext(patch.dict(converter.CONFIG, {"control_store": store})) + converter.invalidate_model_table() + return store + + 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 MEDIA_PATHS and self.media_replies: + self.media_requests.append(request) + status, payload = self.media_replies.pop(0) + return httpx.Response(status, json=payload) + 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": url, "revised_prompt": "synthetic"} for url in urls], + "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_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": rows, + "usage": {"credit": 62.85}}}) + return super().handle_upstream(request) + + # -- images -------------------------------------------------------------------------- + + def test_image_generation_persists_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.assertNotIn("url", item) + self.assertEqual(item["revised_prompt"], "synthetic") + self.assertTrue(base64.b64decode(item["b64_json"]).startswith(b"\x89PNG")) + self.assertEqual(self.store.usage(ttl_hours=72)["images"]["count"], 1) + stored = self.store.list_assets(ttl_hours=72)["items"][0] + self.assertEqual(stored["model"], media_generation.DEFAULT_IMAGE_MODEL) + explicit = self.client.post("/v1/images/generations", + json={"prompt": "a cat", "response_format": "b64_json"}) + self.assertEqual(explicit.status_code, 200, explicit.text) + self.assertEqual(self.store.usage(ttl_hours=72)["images"]["count"], 1) + + def test_upstream_b64_is_persisted_without_public_link(self): + self.media_replies = [(200, {"data": {"data": [ + {"b64_json": base64.b64encode(PNG_BYTES).decode("ascii")}]}})] + result = self.client.post("/v1/images/generations", + json={"model": "hy-image-3.5", "prompt": "a cat"}) + self.assertEqual(result.status_code, 200, result.text) + item = result.json()["data"][0] + self.assertEqual(item["b64_json"], base64.b64encode(PNG_BYTES).decode("ascii")) + self.assertNotIn("url", item) + stored = self.store.list_assets(ttl_hours=72)["items"] + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0]["model"], "hy-image-3.5") + + 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(ttl_hours=72)["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") + self.assertIn("b64_json", result.json()["data"][0]) + self.assertNotIn("url", result.json()["data"][0]) + self.assertEqual(self.store.usage(ttl_hours=72)["images"]["count"], 1) + + def test_data_uri_references_pass_and_oversized_ones_fail(self): + uri = "data:image/png;base64," + base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 30000).decode("ascii") + self.assertGreater(len(uri), media_generation.MAX_PROMPT_CHARS) + edited = self.client.post("/v1/images/edits", json={ + "prompt": "make it blue", "images": [{"image_url": uri}]}) + self.assertEqual(edited.status_code, 200, edited.text[:200]) + self.assertEqual(json.loads(self.media_requests[-1].read())["image"], [uri]) + video = self.client.post("/v1/videos", json={"image_url": uri}) + self.assertEqual(video.status_code, 200, video.text[:200]) + self.assertEqual(json.loads(self.media_requests[-1].read())["image_url"], uri) + sent = len(self.media_requests) + with patch.object(media_generation, "MAX_IMAGE_REF_CHARS", len(uri) - 1): + for path, payload in ( + ("/v1/images/edits", {"prompt": "x", "images": [{"image_url": uri}]}), + ("/v1/videos", {"image_url": uri})): + result = self.client.post(path, json=payload) + self.assertEqual(result.status_code, 400, (path, result.text[:200])) + self.assertEqual(len(self.media_requests), sent) + + def test_blank_prompts_never_reach_the_upstream(self): + source = [{"image_url": f"{COS}/source.png"}] + for blank in (" ", "\n \t"): + for path, payload in ( + ("/v1/images/generations", {"prompt": blank}), + ("/v1/images/edits", {"prompt": blank, "images": source}), + ("/v1/videos", {"prompt": blank}), + ("/v1/videos", {"prompt": blank, "image_url": f"{COS}/source.png"})): + result = self.client.post(path, json=payload) + self.assertEqual(result.status_code, 400, (path, blank, result.text[:200])) + self.assertEqual(self.media_requests, []) + # Multi-line prompts keep their whitespace verbatim. + prompt = " line one\nline two \n" + result = self.client.post("/v1/images/generations", json={"prompt": prompt}) + self.assertEqual(result.status_code, 200, result.text) + self.assertEqual(json.loads(self.media_requests[-1].read())["prompt"], prompt) + + 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": "x", "response_format": "png"}, None), + ({"prompt": "", "model": "hunyuan-image-v3.0"}, 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(ttl_hours=72)["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"}, + {"prompt": "x", "enable_audio": "false"}, + {"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_credit_and_dynamic_catalog(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) + # Static media models defer to upstream even when a synced root table omits them. + 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, 200, result.text) + # Dynamic media models still require their exact account-catalog entry. + missing = self.client.post("/v1/videos", json={ + "prompt": "x", "model": "custom-video-model", "seconds": 4}) + self.assertEqual(missing.status_code, 400) + 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) + # Unsynced accounts also accept static media models. + 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_models_use_shared_alias_disable_and_binding_rules(self): + self.add_account("cn2", "cn-cli") + self.configure_media(profiles=("cn-cli", "cn2")) + self.account_catalogs({"cn-cli": [], "cn2": []}, serves={"cn-cli": [], "cn2": []}) + store = self.install_policy() + source = "hy-image-3.5" + rule = {**converter.model_policy.default_rule(source), "public_id": "hy-alias", + "credential_ids": [self.entries["cn2"]["account_key"]]} + store.update_model(source, rule, 0, known_models=[source]) + result = self.client.post("/v1/images/generations", json={"model": "hy-alias", "prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + self.assertEqual(self.media_requests[-1].headers["x-user-id"], "cn2") + self.assertEqual(json.loads(self.media_requests[-1].read())["model"], source) + public_ids = {item["id"] for item in self.client.get("/v1/models").json()["data"]} + self.assertIn("hy-alias", public_ids) + self.assertNotIn(source, public_ids) + store.update_model(source, {**rule, "enabled": False}, 1, known_models=[source]) + before = len(self.media_requests) + disabled = self.client.post("/v1/images/generations", json={"model": "hy-alias", "prompt": "x"}) + self.assertEqual(disabled.status_code, 404) + self.assertEqual(len(self.media_requests), before) + + def test_video_task_preserves_public_alias(self): + self.configure_media(profiles=("cn-cli",)) + store = self.install_policy() + source = "minimax-video-h3" + rule = {**converter.model_policy.default_rule(source), "public_id": "video-alias"} + store.update_model(source, rule, 0, known_models=[source]) + submitted = self.client.post("/v1/videos", json={"model": "video-alias", "prompt": "x"}) + self.assertEqual(submitted.status_code, 200, submitted.text) + self.assertEqual(submitted.json()["model"], "video-alias") + task_id = submitted.json()["id"] + task = self.store.get_task(task_id) + self.assertEqual(task["model"], source) + self.assertEqual(task["public_model"], "video-alias") + completed = self.client.get(f"/v1/videos/{task_id}") + self.assertEqual(completed.status_code, 200, completed.text) + self.assertEqual(completed.json()["model"], "video-alias") + + 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.media_model_details()} + self.assertEqual(models["custom-video-model"]["media_kinds"], ["video"]) + self.assertEqual(models["custom-video-model"]["media_sources"], ["catalog"]) + self.assertEqual(models["custom-video-model"]["media_defaults"], []) + self.assertEqual(models["hunyuan-image-v3.0"]["media_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 real video query error terminates the task; later polls do not hit upstream again. + 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"], "failed") + self.video_envelope_error = False + recovered = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(recovered["status"], "failed") + + 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(ttl_hours=72)["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 new_video_task(self): + """Submit a video; the simulated upstream reuses one task id, so drop the old row first.""" + self.store._db.execute("DELETE FROM tasks") + self.store._db.commit() + self.downloads = 0 + self.cos_handlers = [] + return self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + + def test_cos_signature_gone_is_terminal(self): + for status in (403, 404, 410): + with self.subTest(status=status): + task_id = self.new_video_task() + self.cos_handlers = [(".mp4", status)] + 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) # Terminal: no further download. + + def test_other_download_statuses_stay_retryable(self): + for status in (408, 429, 400, 502): + with self.subTest(status=status): + task_id = self.new_video_task() + self.cos_handlers = [(".mp4", status)] + held = self.client.get(f"/v1/videos/{task_id}").json() + self.assertNotIn(held["status"], ("completed", "failed")) + 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.assertEqual(self.downloads, 2) + + def test_store_write_failures_keep_the_paid_task_retryable(self): + for error in (OSError("disk full"), sqlite3.OperationalError("database is locked"), + RuntimeError("busy")): + with self.subTest(error=type(error).__name__): + task_id = self.new_video_task() + with patch.object(self.store, "save_asset", side_effect=error): + held = self.client.get(f"/v1/videos/{task_id}").json() + self.assertNotIn(held["status"], ("completed", "failed")) + self.assertIn("重试", held["message"]) + done = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(done["status"], "completed") + + def test_malformed_download_url_is_terminal(self): + with self.assertRaises(converter.MediaDownloadError): + asyncio.run(converter._media_download_bytes("http://[::1/video.mp4", "rid")) + + def test_in_progress_task_has_no_message_without_a_hint(self): + self.video_task_status = "processing" + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + body = self.client.get(f"/v1/videos/{task_id}").json() + self.assertEqual(body["status"], "in_progress") + self.assertNotIn("message", body) + + def test_task_swept_during_refresh_answers_404(self): + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + update = self.store.update_task + + def update_then_sweep(*args, **kwargs): + update(*args, **kwargs) + self.store._db.execute("DELETE FROM tasks") + self.store._db.commit() + + with patch.object(self.store, "update_task", side_effect=update_then_sweep): + result = self.client.get(f"/v1/videos/{task_id}") + self.assertEqual(result.status_code, 404, result.text) + self.assertEqual(result.json()["error"]["code"], "task_not_found") + + 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(ttl_hours=72)["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(ttl_hours=72)["images"]["count"], 0) + + def test_extra_upstream_items_do_not_mask_a_total_failure(self): + self.image_urls = [f"{COS}/{name}.png" for name in "abcde"] + self.cos_handlers = [(".png", 404)] + result = self.client.post("/v1/images/generations", json={"prompt": "x", "n": 4}) + self.assertEqual(result.status_code, 502, result.text[:200]) + self.assertIn("全部 4 张", result.json()["error"]["message"]) + self.assertEqual(self.downloads, 4) # Only the first MAX_IMAGE_ITEMS are processed. + + def test_unexpected_item_error_keeps_the_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"] + download = converter._media_download_bytes + + async def flaky(url, rid): + if url.endswith("b.png"): + raise httpx.InvalidURL("synthetic") + return await download(url, rid) + + with patch.object(converter, "_media_download_bytes", side_effect=flaky): + 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) + asset_id = data[0]["url"].rsplit("/", 1)[-1] + self.assertIsNotNone(self.store.get_asset(asset_id)) # The survivor stays referenced. + self.assertEqual(self.store.usage(ttl_hours=72)["images"]["count"], 1) + + def test_store_unavailable_refuses_before_upstream(self): + for base_url in ("", "https://media.example.com"): + with self.subTest(base_url=base_url), patch.dict(converter.CONFIG, { + "media_public_base_url": base_url, "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. + explicit = self.client.post("/v1/images/generations", + json={"prompt": "x", "response_format": "b64_json"}) + self.assertEqual(explicit.status_code, 200, explicit.text) + self.assertEqual(len(self.media_requests), 1) + self.media_requests.clear() + + 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.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 model route refusal is account-specific for media and can move to the next account. + self.media_replies = [(400, {"error": {"message": "route config not found"}})] + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + self.assertEqual(len(self.media_requests), 4) + # 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) + + # -- account restrictions and session headers ------------------------------------------ + + def restricted_pair(self): + """Two eligible accounts with failover on, so a replay shows up as a second request.""" + self.add_account("cn2", "cn-cli") + # A real control store lets a restriction disable the account instead of only cooling it. + store = ControlStore(self.root / f"control-{self.fixture_sequence}.sqlite3") + self.addCleanup(store.close) + management = SimpleNamespace(gateway=SimpleNamespace(invalidate_model_table=lambda: None)) + self.enterContext(patch.dict(converter.CONFIG, { + "failover_max": 2, "control_store": store, "management": management})) + self.configure_media(profiles=("cn-cli", "cn2", "cn-work", "intl-cli", "intl-work")) + self.media_requests.clear() + + def assert_health(self, restricted=None): + """Only the restricted account is cooled and disabled; the other stays healthy.""" + for uid in ("cn-cli", "cn2"): + state = self.entry_state(uid) + enabled = converter.model_policy.credential_enabled(converter.CONFIG, state) + if uid == restricted: + self.assertGreater(state["fail_until"], time.time(), uid) + self.assertTrue(state["auto_disabled"], uid) + self.assertFalse(enabled, uid) + else: + self.assertLess(state["fail_until"], time.time(), uid) + self.assertFalse(state.get("auto_disabled"), uid) + self.assertTrue(enabled, uid) + + def test_http_restriction_code_disables_the_account_without_replay(self): + flat = {"code": 11140, "msg": "request illegal"} + nested = {"code": "error", "error": {"code": "11140", "message": "request illegal"}} + for status, body in ((403, flat), (429, nested), (500, flat)): + with self.subTest(status=status): + self.restricted_pair() + self.media_replies = [(status, body)] + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, status, result.text) + self.assertEqual(len(self.media_requests), 1) + self.assert_health(self.media_requests[0].headers["x-user-id"]) + + def test_envelope_restriction_code_disables_the_account(self): + for path, payload in (("/v1/images/generations", {"prompt": "x"}), ("/v1/videos", {"prompt": "dance"})): + with self.subTest(path=path): + self.restricted_pair() + self.media_replies = [(200, {"code": 11140, "msg": "request illegal"})] + result = self.client.post(path, json=payload) + self.assertEqual(result.status_code, 502, result.text) + self.assertEqual(result.json()["error"]["upstream_code"], 11140) + self.assertEqual(len(self.media_requests), 1) + self.assert_health(self.media_requests[0].headers["x-user-id"]) + + def test_bound_video_query_records_the_restriction_and_stops_the_task(self): + self.restricted_pair() + self.video_task_status = "processing" + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + bound = self.media_requests[0].headers["x-user-id"] + self.media_replies = [(403, {"code": 11140, "msg": "request illegal"})] + result = self.client.get(f"/v1/videos/{task_id}") + self.assertEqual(result.status_code, 403, result.text) + self.assertEqual([r.headers["x-user-id"] for r in self.media_requests], [bound, bound]) + self.assert_health(bound) + self.assertEqual(self.store.get_task(task_id)["status"], "failed") + self.assertEqual(self.client.get(f"/v1/videos/{task_id}").json()["status"], "failed") + self.assertEqual([r.headers["x-user-id"] for r in self.media_requests], [bound, bound]) + + def test_model_scoped_codes_are_not_replayed_and_do_not_cool(self): + for status, code in ((429, 11129), (403, 11129), (403, 11148)): + with self.subTest(status=status, code=code): + self.restricted_pair() + self.media_replies = [(status, {"code": code, "msg": "synthetic model refusal"})] + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, status, result.text) + self.assertEqual(len(self.media_requests), 1) + self.assert_health() + + def test_unrelated_business_code_keeps_replaying_without_cooling(self): + self.restricted_pair() + self.media_replies = [(403, {"code": 10001, "msg": "synthetic refusal"})] + 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) + self.assert_health() + + def test_media_requests_carry_session_and_trace_headers(self): + self.assertEqual(self.client.post("/v1/images/generations", json={"prompt": "x"}).status_code, 200) + task_id = self.client.post("/v1/videos", json={"prompt": "dance"}).json()["id"] + self.assertEqual(self.client.get(f"/v1/videos/{task_id}").status_code, 200) + self.assertEqual([r.url.path for r in self.media_requests], + ["/v2/images/generations", "/v2/videos/generations", "/v2/videos/tasks"]) + for request in self.media_requests: + headers = request.headers + for name in ("x-conversation-id", "x-request-id", "x-conversation-message-id", "traceparent"): + self.assertTrue(headers.get(name), (request.url.path, name)) + # cn-cli reuses one id for the request and the message. + self.assertEqual(headers["x-request-id"], headers["x-conversation-message-id"]) + self.assertRegex(headers["traceparent"], r"^00-[0-9a-f]{32}-[0-9a-f]{16}-01$") + # Media calls are not session-sticky: each one is its own conversation. + self.assertEqual(len({r.headers["x-conversation-id"] for r in self.media_requests}), 3) + + def test_single_credential_media_requests_carry_session_headers(self): + self.enterContext(patch.dict(converter.CONFIG, { + "cred_pool": None, "cred": self.entries["cn-cli"]["cm"]})) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + headers = self.media_requests[0].headers + for name in ("x-conversation-id", "x-request-id", "x-conversation-message-id", "traceparent"): + self.assertTrue(headers.get(name), name) + + def test_scoped_mode_media_requests_carry_the_request_conversation(self): + self.enterContext(patch.dict(converter.CONFIG, {"request_context_mode": "scoped"})) + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + headers = self.media_requests[0].headers + self.assertRegex(headers["x-conversation-id"], r"^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$") + self.assertEqual(headers["x-root-request-id"], result.headers["x-request-id"]) + self.assertEqual(headers["x-request-id"], headers["x-conversation-message-id"]) + + def test_shared_media_catalog(self): + result = self.client.post("/v1/images/generations", json={"prompt": "x"}) + self.assertEqual(result.status_code, 200, result.text) + models = converter.media_model_details() + by_id = {entry["id"]: entry for entry in models} + self.assertEqual(by_id["hunyuan-image-v3.0"]["media_kinds"], ["image"]) + self.assertEqual(by_id["hunyuan-image-v3.0"]["media_defaults"], ["image"]) + self.assertEqual(by_id["hunyuan-image-v3.0"]["media_sources"], ["static"]) + self.assertEqual(by_id["hunyuan-image-v2.0-general-edit"]["media_defaults"], ["edit"]) + self.assertEqual(by_id["minimax-video-h3"]["media_defaults"], ["video"]) + self.assertNotIn("glm-5.1", by_id) + public = {entry["id"]: entry for entry in converter.model_policy.public_details(converter)} + self.assertEqual(public["hy-image-3.5"]["media_kinds"], ["image", "edit"]) + + +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._media_ttl_hours.side_effect = lambda: float(self.config["media_ttl_hours"]) + self.gateway._media_max_bytes.side_effect = lambda: self.config["media_max_total_mb"] * 1048576 + self.gateway._media_video_refresh = AsyncMock(return_value=self.video_task) + self.gateway._media_video_response = Mock( + 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)) + 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.assertNotIn("models", body) + + 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_expired_assets_disappear_from_admin_views(self): + meta = self.store.save_asset("image", PNG_BYTES, model="m", ttl_hours=72, max_total_bytes=1 << 20) + self.store._db.execute("UPDATE assets SET created=?", (time.time() - 2 * 3600,)) + self.store._db.commit() + path = f"/admin/media/assets/{meta['id']}" + + def views(): + listed = self.client.get("/admin/media/assets", headers=self.headers).json()["items"] + usage = self.client.get("/admin/media", headers=self.headers).json()["usage"] + return (len(listed), self.client.get(path, headers=self.headers).status_code, + self.client.get(path + "/content", headers=self.headers).status_code, + usage["images"]["count"]) + + self.assertEqual(views(), (1, 200, 200, 1)) + self.config["media_ttl_hours"] = 1 # Shrinking the TTL applies before any sweep. + self.assertEqual(views(), (0, 404, 404, 0)) + + def test_media_routes_keep_the_generic_error_envelope(self): + refresh = self.gateway._media_video_refresh + refresh.side_effect = converter.HTTPException(status_code=409, detail="plain text detail") + result = self.client.post("/admin/media/tasks/t1/refresh", headers=self.headers) + self.assertEqual(result.status_code, 409) + self.assertTrue(result.json()["error"]["message"]) + refresh.side_effect = RuntimeError("secret internal detail") + result = self.client.post("/admin/media/tasks/t1/refresh", headers=self.headers) + self.assertEqual(result.status_code, 500) + self.assertEqual(result.json()["error"]["message"], "管理操作失败,请检查存储状态后重试") + self.assertNotIn("secret", result.text) + + def test_unavailable_store_reports_service_error(self): + self.config["media_store"] = None + 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__": + unittest.main() diff --git a/tests/test_preview.py b/tests/test_preview.py index 079a848..ec41f56 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -203,19 +203,26 @@ class PublicTests(unittest.TestCase): def test_guard_refuses_credential_transfer_and_shared_changes(self): app = FastAPI() app.add_api_route("/{path:path}", lambda: {"ok": True}, - methods=["GET", "POST", "PATCH", "DELETE"]) + methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) app.add_middleware(public.Guard) client = TestClient(app) for method, path in (("POST", "/admin/credentials"), ("POST", "/admin/credentials/upload"), ("POST", "/admin/credentials/refresh-import"), ("POST", "/admin/credentials/export"), ("DELETE", "/admin/credentials/aurora.info"), ("PATCH", "/admin/settings"), - ("POST", "/admin/logs/clear"), ("POST", "/admin/update/install")): + ("POST", "/admin/logs/clear"), ("POST", "/admin/update/install"), + ("DELETE", "/admin/media/assets/img_demo"), + ("POST", "/admin/media/tasks/task_demo/refresh"), + ("POST", "/admin/models"), ("PUT", "/admin/models/hy-image-3.5"), + ("DELETE", "/admin/models/custom:demo")): response = client.request(method, path) self.assertEqual(response.status_code, 403, path) + self.assertEqual(response.headers["x-codebuddy-preview"], "public") self.assertEqual(response.json()["error"]["message"], public.MESSAGE) for method, path in (("GET", "/admin/credentials"), ("PATCH", "/admin/credentials/abc"), ("POST", "/admin/credentials/abc/checkin"), ("POST", "/v1/chat/completions")): - self.assertEqual(client.request(method, path).status_code, 200, path) + response = client.request(method, path) + self.assertEqual(response.status_code, 200, path) + self.assertEqual(response.headers["x-codebuddy-preview"], "public") def test_reset_stops_the_server(self): class Immediate: diff --git a/tests/test_region_routing.py b/tests/test_region_routing.py index dd6a7ad..82714f8 100644 --- a/tests/test_region_routing.py +++ b/tests/test_region_routing.py @@ -55,6 +55,11 @@ def success_sse(): + "data: [DONE]\n\n").encode() +def chat_model_ids(response): + """Exclude media routes when asserting the legacy chat catalog.""" + return {item["id"] for item in response.json()["data"] if not item.get("media_kinds")} + + class RegionRoutingTests(unittest.TestCase): def setUp(self): self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) @@ -520,7 +525,7 @@ def test_models_is_merged_and_count_tokens_remains_local(self): response = self.client.get("/v1/models") self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json()["object"], "list") - actual = {item["id"] for item in response.json()["data"]} + actual = chat_model_ids(response) expected = {item["id"] for items in catalogs().values() for item in items} self.assertEqual(actual, expected | {"auto"}) @@ -574,7 +579,7 @@ def test_models_does_not_publish_missing_or_unfunded_product_catalog(self): self.assertEqual(response.status_code, 200, response.text) if response.status_code == 200: expected = {m["id"] for profile in eligible for m in catalogs()[profile]} - self.assertEqual({m["id"] for m in response.json()["data"]} - {"auto"}, expected) + self.assertEqual(chat_model_ids(response) - {"auto"}, expected) if not eligible: self.assertEqual(response.json()["data"], []) self.assertFalse(self.requests) @@ -599,7 +604,7 @@ def test_domestic_same_profile_accounts_cannot_borrow_catalog_or_balance(self): self.assertIn(response.status_code, (200, 503), response.text) if response.status_code == 200: expected = ({"first-only"} if balance != 0 else set()) | ({"second-only"} if missing else set()) - self.assertEqual({m["id"] for m in response.json()["data"]} - {"auto"}, expected) + self.assertEqual(chat_model_ids(response) - {"auto"}, expected) def test_auto_maps_to_each_accounts_declared_default_and_rotates(self): tables = catalogs() diff --git a/tests/test_webui_integration.py b/tests/test_webui_integration.py index c088843..671ba84 100644 --- a/tests/test_webui_integration.py +++ b/tests/test_webui_integration.py @@ -49,6 +49,19 @@ def policy(self, source="shared-model", **kwargs): known_models=[item["id"] for item in self.management.admin_model_inventory()]) return rule + def test_media_models_share_admin_catalog_and_media_preview(self): + self.configure(profiles=("cn-cli",)) + self.account_catalogs({"cn-cli": []}, serves={"cn-cli": []}) + rows = self.client.get("/admin/models").json()["models"] + media = next(row for row in rows if row["id"] == "hy-image-3.5") + self.assertEqual(media["media_kinds"], ["image", "edit"]) + self.assertTrue(media["available"]) + preview = self.client.post("/admin/models/hy-image-3.5/preview", json={}) + self.assertEqual(preview.status_code, 200, preview.text) + self.assertEqual(len(preview.json()["candidates"]), 1) + public = {row["id"]: row for row in self.client.get("/v1/models").json()["data"]} + self.assertEqual(public["hy-image-3.5"]["media_kinds"], ["image", "edit"]) + def test_model_metadata_respects_alias_and_draft_scope_without_exposing_identity(self): tables = {profile: [{**fixtures.model('shared-model', 'x0.00'), 'supportsImages': profile == 'cn-cli', 'descriptionZh': 'safe-' + profile, 'accessToken': 'metadata-secret-canary', @@ -85,7 +98,7 @@ def test_managed_capability_guard_hot_toggle_does_not_remove_metadata(self): self.post_ok('chat/completions', body, {'intl-work'}) data = self.client.get('/admin/models').json() self.assertFalse(data['model_capability_guard']) - self.assertEqual(data['models'][0]['capabilities']['images'], 'unsupported') + self.assertEqual(next(row for row in data['models'] if row['id'] == 'shared-model')['capabilities']['images'], 'unsupported') def test_managed_alias_all_protocols_strict_binding_and_response_names(self): 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..f07bb32 --- /dev/null +++ b/web/e2e/media.spec.ts @@ -0,0 +1,198 @@ +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 }, + }; +} + +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_at: now - 3600, + expires_at: 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 expect(dialog.getByRole("link", { name: "下载" })).toHaveClass(/linkButton/); + await dialog.getByRole("button", { name: "删除", exact: true }).click(); + await expect(dialog).toContainText("不可恢复"); + await dialog.getByRole("button", { name: "取消" }).click(); + await expect(dialog).toContainText("资源详情"); + 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 expect(page.getByRole("tab", { name: "模型", exact: true })).toHaveCount(0); + + 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/api.ts b/web/src/api.ts index 4fe4f08..50151ad 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { installPublicDemoAdapter } from "./publicDemo"; import { create } from "zustand"; import { useCallback, useEffect, useState } from "react"; @@ -15,6 +16,7 @@ export const useSession = create(() => ({ error: null, })); export const api = axios.create({ baseURL: "/admin", withCredentials: true, timeout: 20000 }); +installPublicDemoAdapter(api); api.interceptors.request.use((config) => { const csrf = useSession.getState().csrf; if (csrf) config.headers.set("X-CSRF-Token", csrf); @@ -243,6 +245,9 @@ export type ModelRule = { capabilities?: unknown; limits?: unknown; metadata_by_profile?: unknown; + media_kinds?: string[]; + media_sources?: string[]; + media_defaults?: string[]; }; function rateOverride(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; diff --git a/web/src/components.tsx b/web/src/components.tsx index 1b0295d..d6d6319 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -42,6 +42,9 @@ 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: + "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 ( ; export type MessageKey = keyof typeof zh; @@ -661,6 +736,7 @@ const en: Record = { "common.clearSearch": "Clear search", "common.dismissNotice": "Dismiss notification", "common.closeDrawer": "Close drawer", + "common.cancel": "Cancel", "common.currentApiKey": "Current API key", "logs.title": "Audit logs", "logs.refresh": "Refresh", @@ -936,6 +1012,86 @@ 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: asset library and video tasks.", + "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: media is still stored locally; images return b64 and no public link is generated.", + "media.bannerAction": "Configure", + "media.bannerLocked": + "The asset base URL is locked by startup configuration: media is stored locally without public links.", + "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.filter.all": "All", + "media.filter.kind": "Kind", + "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": + "Generated images and videos appear here; the asset base URL only controls public links.", + "media.empty.tasks": "No video tasks", + "media.remaining": "{time} left", + "media.remaining.days": "{count} days", + "media.remaining.hours": "{count} hours", + "media.remaining.minutes": "{count} min", + "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", + "media.deleting": "Deleting…", + "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.tasks.retrying": "Download deferred, retried on next query", + "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.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 media is stored locally without public links.", + "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..216a19e --- /dev/null +++ b/web/src/media.test.tsx @@ -0,0 +1,267 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, useResource } from "./api"; +import { translate } from "./i18n"; +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 }, +}); + +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_at: 1789000000, + expires_at: 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_at: 1789000000, + expires_at: 1789000000 + 180 * 60, + }, + ], + has_more: false, + next_cursor: null, +}; + +function mockResources(overrides: { summary?: object; tasks?: 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() }; + if (path.startsWith("/media/tasks") && overrides.tasks) + return { + data: overrides.tasks, + 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 and tasks with safe defaults", () => { + expect(summary.usage.images.count).toBe(48); + expect(summary.tasks).toEqual({ active: 2, failed: 1 }); + const empty = normalizeMediaSummary({}); + expect(empty.usage.total_bytes).toBe(0); + expect(empty.tasks).toEqual({ active: 0, failed: 0 }); + 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, translate)).toEqual({ text: "—", warn: false }); + expect(remainingLabel(now / 1000 + 90 * 3600, translate, now)).toEqual({ + text: "4 天", + warn: false, + }); + expect(remainingLabel(now / 1000 + 3 * 3600, translate, now)).toEqual({ + text: "3.0 小时", + warn: true, + }); + expect(remainingLabel(now / 1000 + 20 * 60, translate, now)).toEqual({ + text: "20 分钟", + warn: true, + }); + }); + + it("routes the remaining-time units through the translator", () => { + const now = Date.now(); + const echo = (key: string, vars?: Record) => `${key}|${vars?.count}`; + expect(remainingLabel(now / 1000 + 90 * 3600, echo, now).text).toBe("media.remaining.days|4"); + expect(remainingLabel(now / 1000 + 3 * 3600, echo, now).text).toBe("media.remaining.hours|3.0"); + expect(remainingLabel(now / 1000 + 20 * 60, echo, now).text).toBe("media.remaining.minutes|20"); + }); + + 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]!); + 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", + 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("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("flags active tasks whose download is deferred", () => { + const task = (id: string, status: string, error: string | null) => ({ + task_id: id, + status, + model: "minimax-video-h3", + profile: "cn-cli", + account_key: "acct", + created: 1789000000, + error, + credit: null, + media_id: null, + }); + mockResources({ + tasks: { + items: [ + task( + "task-deferred", + "in_progress", + "资源下载暂缓,下次查询重试:资源下载失败(HTTP 429)", + ), + task("task-queued", "queued", null), + task( + "task-failed", + "failed", + "视频资源下载失败:资源下载失败(HTTP 404,签名可能已过期)", + ), + ], + has_more: false, + next_cursor: null, + }, + }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "视频任务" })); + expect(screen.getAllByText("下载暂缓,查询时重试")).toHaveLength(1); + }); + + it("keeps model management in model routing", () => { + render(); + expect(screen.queryByRole("tab", { name: "模型" })).toBeNull(); + expect(screen.getByRole("tab", { name: "资源库" })).toBeTruthy(); + expect(screen.getByRole("tab", { name: "视频任务" })).toBeTruthy(); + }); + it("confirms deletion in a dialog and styles the download button", async () => { + const remove = vi.spyOn(api, "delete").mockResolvedValue({ data: {} } as never); + render(); + fireEvent.click(screen.getAllByRole("button", { name: /hunyuan-image-v3\.0/ })[0]!); + const download = screen.getByRole("link", { name: "下载" }); + expect(download.className).toContain("linkButton"); + fireEvent.click(screen.getByRole("button", { name: /^删除$/ })); + expect(remove).not.toHaveBeenCalled(); + const confirmation = within(screen.getByRole("dialog")); + expect(confirmation.getByText(/不可恢复/)).toBeTruthy(); + fireEvent.click(confirmation.getByRole("button", { name: "取消" })); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /^删除$/ })); + 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/modelMetadata.test.tsx b/web/src/modelMetadata.test.tsx index c60fc2c..a86ef24 100644 --- a/web/src/modelMetadata.test.tsx +++ b/web/src/modelMetadata.test.tsx @@ -146,12 +146,49 @@ describe("model declarations", () => { ({ data: path === "/models" - ? { revision: 1, models: [model], model_capability_guard: false } + ? { + revision: 1, + models: [ + model, + { + ...model, + id: "hy-image-3.5", + public_id: "hy-image-3.5", + upstream_id: "hy-image-3.5", + capabilities: {}, + limits: {}, + metadata_by_profile: {}, + media_kinds: ["image", "edit"], + }, + { + ...model, + id: "minimax-video-h3", + public_id: "minimax-video-h3", + upstream_id: "minimax-video-h3", + capabilities: {}, + limits: {}, + metadata_by_profile: {}, + media_kinds: ["video"], + }, + ], + model_capability_guard: false, + } : { credentials: [] }, }) as never, ); render(); await screen.findByText("能力预检:关闭"); + expect(screen.getByText("文生图")).toBeTruthy(); + expect(screen.getByText("图生图")).toBeTruthy(); + const capability = screen.getByRole("combobox", { name: "筛选能力" }); + choose(capability, "media_image"); + expect(screen.getByText("hy-image-3.5", { selector: "strong" })).toBeTruthy(); + expect(screen.queryByText("minimax-video-h3", { selector: "strong" })).toBeNull(); + expect(screen.queryByText("public", { selector: "strong" })).toBeNull(); + choose(capability, "media_video"); + expect(screen.getByText("minimax-video-h3", { selector: "strong" })).toBeTruthy(); + expect(screen.queryByText("hy-image-3.5", { selector: "strong" })).toBeNull(); + choose(capability, ""); fireEvent.change(screen.getByRole("textbox", { name: "搜索模型" }), { target: { value: "Display" }, }); 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..a5d33ee --- /dev/null +++ b/web/src/pages/Media.tsx @@ -0,0 +1,760 @@ +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 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 }; +}; + +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, + }, + }; +} + +/** Remaining retention as a short label; under six hours it turns warning-coloured. */ +export function remainingLabel( + expires: unknown, + t: (key: string, vars?: Record) => string, + 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: t("media.remaining.days", { count: Math.round(hours / 24) }), warn: false }; + if (hours >= 1) + return { text: t("media.remaining.hours", { count: hours.toFixed(1) }), warn: hours < 6 }; + return { + text: t("media.remaining.minutes", { count: 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 : ""; + const remove = () => { + setBusy(true); + setError(null); + void api + .delete(`/media/assets/${encodeURIComponent(assetId)}`) + .then(() => { + onDeleted(); + onClose(); + }) + .catch((err: unknown) => setError(errorMessage(err))) + .finally(() => setBusy(false)); + }; + if (confirming) { + return ( + setConfirming(false)} + dismissDisabled={busy} + > + +

{t("media.deleteHint")}

+

{assetId}

+
+ + +
+
+ ); + } + return ( + + +
+ {isVideo ? ( + + ) : ( + {text(item.model) + )} +
+ +
+ {configured && link && ( + + )} + + {t("media.download")} + + +
+ {!configured &&

{t("media.noLinkNote")}

} +
+ ); +} + +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">("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")], + ] as const + ).map(([value, label]) => ( + + ))} +
+ + {tab === "library" && ( + <> +
+ +
+ + {assets.data && + (assets.data.items.length ? ( +
+ {assets.data.items.map((item) => { + const remaining = remainingLabel(item.expires_at, t); + 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.public_model || 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", + )} + + {active && item.error ? ( + <> + {" "} + {t("media.tasks.retrying")} + + ) : null} + {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} 页 +
+ + +
+
+ + )} + + {preview && ( + setPreview(null)} + onDeleted={() => { + assets.reload(); + summary.reload(); + }} + /> + )} + {settingsOpen && ( + setSettingsOpen(false)} + onSaved={() => { + summary.reload(); + assets.reload(); + }} + /> + )} + + ); +} diff --git a/web/src/pages/Models.tsx b/web/src/pages/Models.tsx index c50669b..6b65e3a 100644 --- a/web/src/pages/Models.tsx +++ b/web/src/pages/Models.tsx @@ -59,6 +59,7 @@ import s from "../ui.module.scss"; import { SearchInput, Select } from "../fields"; import { clearToasts, notify } from "../toast"; export const profiles = ["cn-cli", "cn-work", "intl-cli", "intl-work"]; +const mediaKindLabels: Record = { image: "文生图", edit: "图生图", video: "视频" }; const validId = (id: string) => /^[A-Za-z0-9_.:/@-]{1,160}$/.test(id) && ![".", ".."].includes(id); export function validateRule(rule: ModelRule, models: ModelRule[]): string | null { if (!rule.public_id.trim()) return "对外 ID 不能为空"; @@ -561,7 +562,11 @@ function matchesCatalog(model: ModelRule, query: string, filter: CatalogFilter): if (bindingMode(model) !== "auto") return false; } else if (model.region !== filter.region) return false; } - if (filter.capability) { + if (filter.capability === "media_image") { + if (!model.media_kinds?.some((kind) => kind === "image" || kind === "edit")) return false; + } else if (filter.capability === "media_video") { + if (!model.media_kinds?.includes("video")) return false; + } else if (filter.capability) { const declared = model.capabilities && typeof model.capabilities === "object" ? (model.capabilities as RecordValue) @@ -806,6 +811,8 @@ export function Models() { onChange={(value) => setFilter({ ...filter, capability: value })} > + + @@ -915,6 +922,11 @@ export function Models() { {m.public_id} {!m.enabled && 已停用} {m.custom && 自建} + {m.media_kinds?.map((kind) => ( + + {mediaKindLabels[kind] ?? kind} + + ))} {m.available === false && m.enabled && ( 暂无候选 )} diff --git a/web/src/publicDemo.test.ts b/web/src/publicDemo.test.ts new file mode 100644 index 0000000..5999348 --- /dev/null +++ b/web/src/publicDemo.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import axios from "axios"; +import type { AxiosAdapter, AxiosResponse, InternalAxiosRequestConfig } from "axios"; +import { createPublicDemoAdapter, installPublicDemoAdapter } from "./publicDemo"; + +function config(method: string, url: string, data?: unknown): InternalAxiosRequestConfig { + return { + method, + url, + data: data === undefined ? undefined : JSON.stringify(data), + headers: {}, + } as InternalAxiosRequestConfig; +} + +function reply(config: InternalAxiosRequestConfig, data: unknown, status = 200): AxiosResponse { + return { + data, + status, + statusText: String(status), + headers: { "x-codebuddy-preview": "public" }, + config, + }; +} + +const image = { + id: "img_demo", + kind: "image", + mime: "image/png", + size: 400, + sha256: "a".repeat(64), + model: "hy-image-3.5", + created_at: 100, + expires_at: 200, +}; +const video = { + ...image, + id: "vid_demo", + kind: "video", + mime: "video/mp4", + size: 200, + model: "minimax-video-h3", +}; +const modelRule = { + enabled: true, + keep_original: false, + region: "", + profile: "", + credential_ids: [], +}; + +describe("public preview browser-local adapter", () => { + it("keeps media deletion, settings and task refresh local while allowing reads", async () => { + const calls: InternalAxiosRequestConfig[] = []; + const delegate = vi.fn(async (request: InternalAxiosRequestConfig) => { + calls.push(request); + const path = new URL(request.url || "", "http://preview.local").pathname; + if (path === "/media") { + return reply(request, { + configured: false, + base_url: "", + available: true, + limit_bytes: 1000, + ttl_hours: 72, + usage: { + total_bytes: 600, + images: { count: 1, bytes: 400 }, + videos: { count: 1, bytes: 200 }, + }, + tasks: { active: 1, failed: 0 }, + }); + } + if (path === "/media/assets") + return reply( + request, + JSON.stringify({ items: [image, video], has_more: false, next_cursor: null }), + ); + if (path === "/media/tasks") { + return reply(request, { + items: [ + { + task_id: "task_demo", + model: "minimax-video-h3", + status: "in_progress", + created: 100, + media_id: null, + error: null, + }, + ], + has_more: false, + next_cursor: null, + }); + } + if (path === "/settings") { + return reply(request, { + revision: 4, + items: [{ key: "media_public_base_url", value: "", locked: false }], + audit: {}, + session: {}, + }); + } + if (path === "/media/assets/vid_demo") return reply(request, video); + return reply(request, { ok: true }); + }) as AxiosAdapter; + const adapter = createPublicDemoAdapter(delegate); + + await adapter(config("get", "/media")); + await adapter(config("get", "/media/assets?kind=image")); + await adapter(config("get", "/media/tasks")); + await adapter(config("delete", "/media/assets/img_demo")); + expect(calls.filter((request) => request.method === "delete")).toHaveLength(0); + + const assets = await adapter(config("get", "/media/assets")); + expect(assets.data.items.map((item: { id: string }) => item.id)).toEqual(["vid_demo"]); + const summary = await adapter(config("get", "/media")); + expect(summary.data.usage).toEqual({ + total_bytes: 200, + images: { count: 0, bytes: 0 }, + videos: { count: 1, bytes: 200 }, + }); + + const refreshed = await adapter(config("post", "/media/tasks/task_demo/refresh")); + expect(refreshed.data.status).toBe("completed"); + expect(refreshed.data.media_id).toBe("vid_demo"); + expect(calls.filter((request) => request.method === "post")).toHaveLength(0); + expect((await adapter(config("get", "/media"))).data.tasks.active).toBe(0); + + const patched = await adapter( + config("patch", "/settings", { + revision: 4, + values: { media_public_base_url: "https://demo.example" }, + }), + ); + expect(patched.data.revision).toBe(5); + expect(calls.filter((request) => request.method === "patch")).toHaveLength(0); + const settings = await adapter(config("get", "/settings")); + expect(settings.data.items[0].value).toBe("https://demo.example"); + expect((await adapter(config("get", "/media"))).data.configured).toBe(true); + expect((await adapter(config("get", "/media/assets/vid_demo"))).data.public_url).toBe( + "https://demo.example/v1/media/videos/vid_demo", + ); + + await adapter(config("delete", "/credentials/demo")); + expect(calls.at(-1)?.url).toBe("/credentials/demo"); + }); + + it("keeps model edits in the tab and resets from a fresh adapter", async () => { + const calls: InternalAxiosRequestConfig[] = []; + const models = [ + { + id: "hy-image-3.5", + ...modelRule, + public_id: "hy-image-3.5", + upstream_id: "hy-image-3.5", + media_kinds: ["image", "edit"], + }, + { + id: "minimax-video-h3", + ...modelRule, + public_id: "minimax-video-h3", + upstream_id: "minimax-video-h3", + media_kinds: ["video"], + }, + ]; + const delegate = vi.fn(async (request: InternalAxiosRequestConfig) => { + calls.push(request); + const path = new URL(request.url || "", "http://preview.local").pathname; + if (path === "/models") return reply(request, { revision: 7, models, model_guard: true }); + return reply(request, { candidates: [], excluded: [] }); + }) as AxiosAdapter; + const adapter = createPublicDemoAdapter(delegate); + await adapter(config("get", "/models")); + + await adapter( + config("put", "/models/hy-image-3.5", { + ...modelRule, + revision: 7, + public_id: "hy-local", + upstream_id: "hy-image-3.5", + }), + ); + let view = await adapter(config("get", "/models")); + expect(view.data.revision).toBe(8); + expect(view.data.models[0].public_id).toBe("hy-local"); + expect(view.data.models[0].media_kinds).toEqual(["image", "edit"]); + + const created = await adapter( + config("post", "/models", { + ...modelRule, + revision: 8, + public_id: "local-video", + upstream_id: "minimax-video-h3", + }), + ); + expect(created.status).toBe(201); + view = await adapter(config("get", "/models")); + expect(view.data.models.at(-1)).toMatchObject({ + public_id: "local-video", + media_kinds: ["video"], + custom: true, + }); + await adapter(config("delete", "/models/custom:browser-1", { revision: 9 })); + view = await adapter(config("get", "/models")); + expect(view.data.models).toHaveLength(2); + + await adapter(config("patch", "/settings", { revision: 10, values: { model_guard: false } })); + view = await adapter(config("get", "/models")); + expect(view.data.model_guard).toBe(false); + expect( + calls + .filter((request) => ["put", "post", "patch", "delete"].includes(request.method || "")) + .map((request) => request.url), + ).toEqual([]); + + const fresh = createPublicDemoAdapter(delegate); + const initial = await fresh(config("get", "/models")); + expect(initial.data.revision).toBe(7); + expect(initial.data.models[0].public_id).toBe("hy-image-3.5"); + expect(initial.data.models).toHaveLength(2); + }); + it("installs around the default adapter array", async () => { + const delegate = vi.fn(async (request: InternalAxiosRequestConfig) => + reply(request, { configured: false, base_url: "", usage: {}, tasks: {} }), + ); + const api = axios.create({ adapter: [delegate] }); + installPublicDemoAdapter(api); + await api.get("/media"); + await api.delete("/media/assets/img_demo"); + expect(delegate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/publicDemo.ts b/web/src/publicDemo.ts new file mode 100644 index 0000000..5a973e2 --- /dev/null +++ b/web/src/publicDemo.ts @@ -0,0 +1,333 @@ +import { + AxiosError, + getAdapter, + type AxiosAdapter, + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from "axios"; + +type Json = Record; +type State = { + active: boolean; + revision: number | null; + serverBase: string; + assets: Map; + deletedAssets: Map; + tasks: Map; + taskOverrides: Map; + activeDelta: number; + settings: Json | null; + settingValues: Json; + serverModels: Json[]; + modelRules: Map; + deletedModels: Set; + customSequence: number; +}; + +const PREVIEW_HEADER = "x-codebuddy-preview"; +const LOCAL_SETTINGS = new Set([ + "media_public_base_url", + "media_max_total_mb", + "media_ttl_hours", + "model_guard", + "model_capability_guard", +]); +const MEDIA_FIELDS = [ + "media_kinds", + "media_sources", + "media_defaults", + "capabilities", + "limits", + "metadata_by_profile", +]; +const clone = (value: T): T => JSON.parse(JSON.stringify(value)); +const initialState = (): State => ({ + active: false, + revision: null, + serverBase: "", + assets: new Map(), + deletedAssets: new Map(), + tasks: new Map(), + taskOverrides: new Map(), + activeDelta: 0, + settings: null, + settingValues: {}, + serverModels: [], + modelRules: new Map(), + deletedModels: new Set(), + customSequence: 0, +}); + +function response(config: InternalAxiosRequestConfig, data: unknown, status = 200): AxiosResponse { + return { + data, + status, + statusText: String(status), + headers: { [PREVIEW_HEADER]: "public" }, + config, + }; +} + +function requestBody(config: InternalAxiosRequestConfig): Json { + if (typeof config.data === "string") return JSON.parse(config.data); + return (config.data || {}) as Json; +} + +function notePublic(result: AxiosResponse, state: State) { + const headers = result.headers as any; + const marker = headers?.get?.(PREVIEW_HEADER) ?? headers?.[PREVIEW_HEADER]; + if (marker === "public") state.active = true; + return result; +} + +function noteRevision(state: State, value: unknown) { + if (state.revision === null && Number.isInteger(value)) state.revision = Number(value); +} + +function nextRevision(state: State, fallback: unknown) { + const base = state.revision ?? (Number.isInteger(fallback) ? Number(fallback) : 0); + state.revision = base + 1; + return state.revision; +} + +function effectiveBase(state: State): string { + return typeof state.settingValues.media_public_base_url === "string" + ? state.settingValues.media_public_base_url + : state.serverBase; +} + +function assetView(item: Json, state: State): Json { + const next = clone(item); + const base = effectiveBase(state).replace(/\/+$/, ""); + if (base) + next.public_url = `${base}/v1/media/${next.kind === "video" ? "videos" : "images"}/${next.id}`; + else delete next.public_url; + return next; +} + +function settingsView(state: State): Json { + const data = clone(state.settings || { items: [], audit: {}, session: {} }); + data.revision = state.revision ?? data.revision ?? 0; + data.items = (data.items || []).map((item: Json) => + Object.hasOwn(state.settingValues, item.key) + ? { ...item, value: state.settingValues[item.key] } + : item, + ); + return data; +} + +function summaryView(raw: Json, state: State): Json { + const data = clone(raw); + data.base_url = effectiveBase(state); + data.configured = Boolean(data.base_url); + if (Object.hasOwn(state.settingValues, "media_max_total_mb")) + data.limit_bytes = Number(state.settingValues.media_max_total_mb) * 1048576; + if (Object.hasOwn(state.settingValues, "media_ttl_hours")) + data.ttl_hours = state.settingValues.media_ttl_hours; + const usage = data.usage || { + total_bytes: 0, + images: { count: 0, bytes: 0 }, + videos: { count: 0, bytes: 0 }, + }; + for (const item of state.deletedAssets.values()) { + const group = item.kind === "video" ? usage.videos : usage.images; + if (group) { + group.count = Math.max(0, Number(group.count || 0) - 1); + group.bytes = Math.max(0, Number(group.bytes || 0) - Number(item.size || 0)); + } + usage.total_bytes = Math.max(0, Number(usage.total_bytes || 0) - Number(item.size || 0)); + } + data.usage = usage; + if (data.tasks) + data.tasks.active = Math.max(0, Number(data.tasks.active || 0) + state.activeDelta); + return data; +} + +function inheritedFacts(state: State, upstream: string): Json { + const source = state.serverModels.find((item) => item.id === upstream); + if (!source) return {}; + return Object.fromEntries( + MEDIA_FIELDS.filter((key) => key in source).map((key) => [key, clone(source[key])]), + ); +} + +function modelsView(raw: Json, state: State): Json { + const data = clone(raw); + const output = []; + const present = new Set(); + for (const item of data.models || []) { + if (state.deletedModels.has(item.id)) continue; + present.add(item.id); + const local = state.modelRules.get(item.id); + output.push(local ? { ...item, ...clone(local), id: item.id } : item); + } + for (const [id, rule] of state.modelRules) { + if (present.has(id) || state.deletedModels.has(id)) continue; + output.push({ ...inheritedFacts(state, String(rule.upstream_id || "")), ...clone(rule), id }); + } + data.models = output; + data.revision = state.revision ?? data.revision; + for (const key of ["model_guard", "model_capability_guard"]) { + if (Object.hasOwn(state.settingValues, key)) data[key] = state.settingValues[key]; + } + return data; +} + +function notFound(config: InternalAxiosRequestConfig) { + const result = response( + config, + { error: { message: "媒体资源不存在或已清理", type: "not_found_error" } }, + 404, + ); + throw new AxiosError("媒体资源不存在或已清理", "ERR_BAD_REQUEST", config, undefined, result); +} + +function localWrite( + config: InternalAxiosRequestConfig, + path: string, + method: string, + state: State, +): AxiosResponse | null { + const body = requestBody(config); + if (method === "DELETE" && path.startsWith("/media/assets/")) { + const id = decodeURIComponent(path.slice("/media/assets/".length)); + if (!id || id.includes("/")) return null; + state.deletedAssets.set(id, state.assets.get(id) || { id }); + return response(config, { deleted: id }); + } + const taskMatch = path.match(/^\/media\/tasks\/([^/]+)\/refresh$/); + if (method === "POST" && taskMatch) { + const id = decodeURIComponent(taskMatch[1]!); + const current = state.tasks.get(id) || { task_id: id, model: "", created: Date.now() / 1000 }; + const previous = state.taskOverrides.get(id) || current; + if (["queued", "in_progress"].includes(previous.status)) state.activeDelta -= 1; + const video = [...state.assets.values()].find( + (item) => item.kind === "video" && !state.deletedAssets.has(item.id), + ); + const next: Json = { + ...clone(previous), + status: "completed", + error: null, + media_id: video?.id || null, + }; + state.taskOverrides.set(id, next); + return response(config, { ...next, id, object: "video", created_at: next.created }); + } + if (method === "PATCH" && path === "/settings") { + const values = body.values || {}; + const keys = Object.keys(values); + if (!keys.length || keys.some((key) => !LOCAL_SETTINGS.has(key))) return null; + Object.assign(state.settingValues, clone(values)); + nextRevision(state, body.revision); + return response(config, settingsView(state)); + } + if (method === "POST" && path === "/models") { + const id = `custom:browser-${++state.customSequence}`; + const rule: Json = { ...clone(body), id, custom: true }; + delete rule.revision; + state.modelRules.set(id, rule); + const revision = nextRevision(state, body.revision); + return response(config, { revision, model: rule }, 201); + } + const modelMatch = path.match(/^\/models\/([^/]+)$/); + if (modelMatch && method === "PUT") { + const id = decodeURIComponent(modelMatch[1]!); + const rule: Json = { ...clone(body), id }; + delete rule.revision; + state.modelRules.set(id, rule); + const revision = nextRevision(state, body.revision); + return response(config, { revision, model: rule }); + } + if (modelMatch && method === "DELETE") { + const id = decodeURIComponent(modelMatch[1]!); + state.deletedModels.add(id); + const revision = nextRevision(state, body.revision); + return response(config, { revision, ok: true }); + } + return null; +} + +function localRead( + config: InternalAxiosRequestConfig, + result: AxiosResponse, + path: string, + method: string, + state: State, +): AxiosResponse { + if (method !== "GET" || !result.data) return result; + let data: unknown = result.data; + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + return result; + } + } + if (!data || typeof data !== "object") return result; + const raw = data as Json; + if (path === "/media") { + state.serverBase = typeof raw.base_url === "string" ? raw.base_url : state.serverBase; + return { ...result, data: summaryView(raw, state) }; + } + if (path === "/media/assets") { + for (const item of raw.items || []) state.assets.set(item.id, clone(item)); + const visibleItems = (raw.items || []) + .filter((item: Json) => !state.deletedAssets.has(item.id)) + .map((item: Json) => assetView(item, state)); + return { ...result, data: { ...raw, items: visibleItems } }; + } + if (path === "/media/tasks") { + for (const item of raw.items || []) state.tasks.set(item.task_id, clone(item)); + return { + ...result, + data: { + ...raw, + items: (raw.items || []).map((item: Json) => { + const next = { ...item, ...clone(state.taskOverrides.get(item.task_id) || {}) }; + if (state.deletedAssets.has(next.media_id)) next.media_id = null; + return next; + }), + }, + }; + } + if (path === "/settings") { + noteRevision(state, raw.revision); + state.settings = clone(raw); + return { ...result, data: settingsView(state) }; + } + if (path === "/models") { + noteRevision(state, raw.revision); + state.serverModels = clone(raw.models || []); + return { ...result, data: modelsView(raw, state) }; + } + if (path.startsWith("/media/assets/") && !path.endsWith("/content")) { + const id = decodeURIComponent(path.slice("/media/assets/".length)); + if (state.deletedAssets.has(id)) notFound(config); + state.assets.set(id, clone(raw)); + return { ...result, data: assetView(raw, state) }; + } + return result; +} + +/** Run shared-preview mutations in this tab; a reload creates a fresh state. */ +export function createPublicDemoAdapter(delegate: AxiosAdapter): AxiosAdapter { + const state = initialState(); + return async (config) => { + const path = new URL(config.url || "", "http://preview.local").pathname; + const method = (config.method || "get").toUpperCase(); + if (state.active) { + const local = localWrite(config, path, method, state); + if (local) return notePublic(local, state); + } + const result = await delegate(config); + const wasActive = state.active; + notePublic(result, state); + return wasActive ? localRead(config, result, path, method, state) : result; + }; +} + +export function installPublicDemoAdapter(api: AxiosInstance) { + const delegate = api.defaults.adapter; + if (delegate) api.defaults.adapter = createPublicDemoAdapter(getAdapter(delegate)); +} 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 c5cf1f2..1fd6b86 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -82,6 +82,8 @@ const labels: Record = { fetched_at: "同步时间", updated_at: "更新时间", created_at: "创建时间", + mime: "MIME 类型", + sha256: "SHA-256", generated_at: "生成时间", last_failure_at: "最近失败", token_expires_at: "令牌到期", @@ -314,6 +316,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",