From 0a5db546faff26627cf3d74d308cd858e85a899f Mon Sep 17 00:00:00 2001 From: Gateway Date: Wed, 1 Apr 2026 15:58:29 +0700 Subject: [PATCH 1/3] Harden artifacts, downloads, and callback handling --- .github/workflows/tests.yml | 34 +++++++++++ src/kie_api/__init__.py | 4 ++ src/kie_api/artifacts/__init__.py | 4 ++ src/kie_api/artifacts/index.py | 87 +++++++++++++++++++++------ src/kie_api/artifacts/models.py | 18 ++++++ src/kie_api/artifacts/writer.py | 89 +++++++++++++++++++++++++--- src/kie_api/clients/__init__.py | 4 ++ src/kie_api/clients/callbacks.py | 42 +++++++++++++ src/kie_api/clients/download.py | 71 +++++++++++++++++++--- src/kie_api/config.py | 37 +++++++++++- src/kie_api/exceptions.py | 4 ++ tests/test_artifact_querying.py | 21 +++++++ tests/test_artifact_writer.py | 98 +++++++++++++++++++++++++++++-- tests/test_callbacks.py | 58 ++++++++++++++++++ tests/test_download_client.py | 80 +++++++++++++++++++++++++ 15 files changed, 611 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e305e92 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.11"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Verify packaged specs are in sync + run: python scripts/sync_packaged_specs.py --check + + - name: Run test suite + run: python -m pytest -q diff --git a/src/kie_api/__init__.py b/src/kie_api/__init__.py index 741d658..8a07388 100644 --- a/src/kie_api/__init__.py +++ b/src/kie_api/__init__.py @@ -35,6 +35,8 @@ from .artifacts import ( ArtifactSource, ArtifactDerivativeSettings, + ArtifactPrivacyMode, + ArtifactPrivacySettings, AssetRecord, DerivedAssetRecord, PromptRecord, @@ -90,6 +92,8 @@ "apply_enhanced_prompt", "append_run_index", "ArtifactDerivativeSettings", + "ArtifactPrivacyMode", + "ArtifactPrivacySettings", "ArtifactSource", "AssetRecord", "build_submission_payload", diff --git a/src/kie_api/artifacts/__init__.py b/src/kie_api/artifacts/__init__.py index 2be84b9..ad88c73 100644 --- a/src/kie_api/artifacts/__init__.py +++ b/src/kie_api/artifacts/__init__.py @@ -20,6 +20,8 @@ from .models import ( ArtifactSource, ArtifactDerivativeSettings, + ArtifactPrivacyMode, + ArtifactPrivacySettings, AssetRecord, DerivedAssetRecord, PromptRecord, @@ -38,6 +40,8 @@ "append_run_index", "ArtifactSource", "ArtifactDerivativeSettings", + "ArtifactPrivacyMode", + "ArtifactPrivacySettings", "AssetRecord", "build_poster_command", "build_run_id", diff --git a/src/kie_api/artifacts/index.py b/src/kie_api/artifacts/index.py index 544064e..3c59ae5 100644 --- a/src/kie_api/artifacts/index.py +++ b/src/kie_api/artifacts/index.py @@ -3,36 +3,48 @@ from __future__ import annotations import json +import os +from contextlib import contextmanager from pathlib import Path -from typing import Iterable, List, Optional +from tempfile import NamedTemporaryFile +from typing import IO, Iterable, Iterator, List, Optional +from pydantic import ValidationError from .models import RunArtifact, RunIndexEntry, RunManifest +try: # pragma: no cover - Windows fallback + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None + def append_run_index(output_root: Path, entry: RunIndexEntry) -> Path: - index_path = Path(output_root) / "index.jsonl" + index_path = _index_path(output_root) index_path.parent.mkdir(parents=True, exist_ok=True) - existing = {item.run_id for item in load_run_index(output_root)} - if entry.run_id in existing: - return index_path - with index_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(entry.model_dump(), ensure_ascii=True)) - handle.write("\n") + with index_path.open("a+", encoding="utf-8") as handle: + with _locked_file(handle): + # Re-read while holding the file lock so concurrent writers do not append duplicates. + handle.seek(0) + existing = { + item.run_id + for item in _parse_index_lines(handle.readlines(), strict=False) + } + if entry.run_id in existing: + return index_path + handle.seek(0, os.SEEK_END) + handle.write(json.dumps(entry.model_dump(), ensure_ascii=True)) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) return index_path def load_run_index(output_root: Path) -> List[RunIndexEntry]: - index_path = Path(output_root) / "index.jsonl" + index_path = _index_path(output_root) if not index_path.exists(): return [] - entries: List[RunIndexEntry] = [] with index_path.open("r", encoding="utf-8") as handle: - for line in handle: - line = line.strip() - if not line: - continue - entries.append(RunIndexEntry.model_validate(json.loads(line))) - return entries + return _parse_index_lines(handle.readlines(), strict=False) def list_recent_runs(output_root: Path, *, limit: int = 10) -> List[RunIndexEntry]: @@ -59,6 +71,12 @@ def list_runs_by_tag(output_root: Path, tag: str, *, limit: Optional[int] = None def get_run_by_id(output_root: Path, run_id: str) -> Optional[RunArtifact]: + for entry in load_run_index(output_root): + if entry.run_id != run_id or not entry.run_path: + continue + run_dir = Path(output_root) / entry.run_path + if run_dir.exists(): + return load_run_artifact(run_dir) for run_dir in scan_run_artifacts(output_root): if run_dir.name == run_id: return load_run_artifact(run_dir) @@ -114,15 +132,48 @@ def rebuild_run_index(output_root: Path) -> Path: from .writer import build_run_index_entry root = Path(output_root) - index_path = root / "index.jsonl" + index_path = _index_path(root) entries: List[RunIndexEntry] = [] for run_dir in scan_run_artifacts(root): run = load_run_artifact(run_dir) manifest = load_run_manifest(run_dir) entries.append(build_run_index_entry(run, manifest)) entries.sort(key=lambda item: item.created_at) - with index_path.open("w", encoding="utf-8") as handle: + index_path.parent.mkdir(parents=True, exist_ok=True) + with NamedTemporaryFile("w", encoding="utf-8", dir=index_path.parent, delete=False) as handle: + temp_path = Path(handle.name) for entry in entries: handle.write(json.dumps(entry.model_dump(), ensure_ascii=True)) handle.write("\n") + os.replace(temp_path, index_path) return index_path + + +def _index_path(output_root: Path) -> Path: + return Path(output_root) / "index.jsonl" + + +def _parse_index_lines(lines: Iterable[str], *, strict: bool) -> List[RunIndexEntry]: + entries: List[RunIndexEntry] = [] + for line in lines: + line = line.strip() + if not line: + continue + try: + entries.append(RunIndexEntry.model_validate(json.loads(line))) + except (json.JSONDecodeError, ValidationError): + if strict: + raise + return entries + + +@contextmanager +def _locked_file(handle: IO[str]) -> Iterator[None]: + if fcntl is None: # pragma: no cover - Windows fallback + yield + return + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) diff --git a/src/kie_api/artifacts/models.py b/src/kie_api/artifacts/models.py index f385c05..534813b 100644 --- a/src/kie_api/artifacts/models.py +++ b/src/kie_api/artifacts/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +from enum import Enum from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -37,6 +38,21 @@ class ArtifactDerivativeSettings(BaseModel): enable_sha256: bool = True +class ArtifactPrivacyMode(str, Enum): + REDACTED = "redacted" + FULL = "full" + + +class ArtifactPrivacySettings(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: ArtifactPrivacyMode = ArtifactPrivacyMode.REDACTED + + @property + def persist_sensitive_fields(self) -> bool: + return self.mode == ArtifactPrivacyMode.FULL + + class RunSourceContext(BaseModel): model_config = ConfigDict(extra="forbid") @@ -136,6 +152,7 @@ class RunArtifactCreateRequest(BaseModel): tags: List[str] = Field(default_factory=list) notes: Optional[str] = None derivative_settings: ArtifactDerivativeSettings = Field(default_factory=ArtifactDerivativeSettings) + privacy: ArtifactPrivacySettings = Field(default_factory=ArtifactPrivacySettings) request_payload: Optional[Dict[str, Any]] = None submit_payload: Optional[Dict[str, Any]] = None submit_response: Optional[Dict[str, Any]] = None @@ -210,6 +227,7 @@ class RunArtifact(BaseModel): tags: List[str] = Field(default_factory=list) notes: Optional[str] = None derivative_settings: ArtifactDerivativeSettings = Field(default_factory=ArtifactDerivativeSettings) + privacy: ArtifactPrivacySettings = Field(default_factory=ArtifactPrivacySettings) manifest_path: str notes_path: str request_path: Optional[str] = None diff --git a/src/kie_api/artifacts/writer.py b/src/kie_api/artifacts/writer.py index 150e52c..505748d 100644 --- a/src/kie_api/artifacts/writer.py +++ b/src/kie_api/artifacts/writer.py @@ -14,12 +14,14 @@ ArtifactSource, AssetRecord, ArtifactDerivativeSettings, + ArtifactPrivacySettings, PromptRecord, ProviderTrace, RunArtifact, RunArtifactCreateRequest, RunIndexEntry, RunManifest, + RunSourceContext, ) from .paths import coerce_created_at, create_run_artifact_paths @@ -43,6 +45,7 @@ def create_run_artifact( paths.run_dir, paths.inputs_dir, derivative_settings=request.derivative_settings, + privacy=request.privacy, ) outputs = _copy_outputs( request.outputs, @@ -51,10 +54,22 @@ def create_run_artifact( paths.web_dir, paths.thumb_dir, derivative_settings=request.derivative_settings, + privacy=request.privacy, ) prompt_record = _write_prompts(paths.run_dir, paths.prompt_txt, paths.prompt_enhanced_txt, request.prompts) - request_path = _write_optional_json(paths.run_dir, paths.request_json, request.request_payload) - provider_trace, log_paths = _write_provider_trace(paths.run_dir, paths.logs_dir, request.provider_trace, request) + request_path = _write_optional_json( + paths.run_dir, + paths.request_json, + request.request_payload, + privacy=request.privacy, + ) + provider_trace, log_paths = _write_provider_trace( + paths.run_dir, + paths.logs_dir, + request.provider_trace, + request, + privacy=request.privacy, + ) run = RunArtifact( run_id=run_id, @@ -64,8 +79,8 @@ def create_run_artifact( provider_model=request.provider_model, task_mode=request.task_mode, run_dir=str(paths.run_dir), - source_metadata=request.source_metadata, - source_context=request.source_context, + source_metadata=_sanitize_mapping(request.source_metadata, privacy=request.privacy), + source_context=_sanitize_source_context(request.source_context, privacy=request.privacy), prompts=prompt_record, inputs=inputs, outputs=outputs, @@ -77,6 +92,7 @@ def create_run_artifact( tags=request.tags, notes=request.notes, derivative_settings=request.derivative_settings, + privacy=request.privacy, manifest_path=str(paths.manifest_json.relative_to(paths.run_dir)), notes_path=str(paths.notes_md.relative_to(paths.run_dir)), request_path=request_path, @@ -161,6 +177,7 @@ def _copy_inputs( inputs_dir: Path, *, derivative_settings: ArtifactDerivativeSettings, + privacy: ArtifactPrivacySettings, ) -> List[AssetRecord]: records: List[AssetRecord] = [] counters: Dict[str, int] = {} @@ -182,6 +199,7 @@ def _copy_inputs( source_url=source.source_url, metadata=source.metadata, enable_sha256=derivative_settings.enable_sha256, + privacy=privacy, ) ) return records @@ -195,6 +213,7 @@ def _copy_outputs( thumb_dir: Path, *, derivative_settings: ArtifactDerivativeSettings, + privacy: ArtifactPrivacySettings, ) -> List[AssetRecord]: records: List[AssetRecord] = [] for index, source in enumerate(sources, start=1): @@ -211,6 +230,7 @@ def _copy_outputs( source_url=source.source_url, metadata=source.metadata, enable_sha256=derivative_settings.enable_sha256, + privacy=privacy, ) if source.kind == "image": web_path = web_dir / f"output_{index:02d}.{derivative_settings.image_web_format.lstrip('.')}" @@ -278,10 +298,15 @@ def _write_provider_trace( logs_dir: Path, trace: ProviderTrace, request: RunArtifactCreateRequest, + *, + privacy: ArtifactPrivacySettings, ) -> tuple[ProviderTrace, Dict[str, str]]: - record = trace.model_copy(deep=True) + record = _sanitize_provider_trace(trace, privacy=privacy) logs: Dict[str, str] = {} + if not privacy.persist_sensitive_fields: + return record, logs + for filename, payload, attr_name in ( ("submit_payload.json", request.submit_payload, "submit_payload_path"), ("submit_response.json", request.submit_response, "submit_response_path"), @@ -311,6 +336,7 @@ def _asset_record( source_url: Optional[str], metadata: Dict[str, Any], enable_sha256: bool, + privacy: ArtifactPrivacySettings, ) -> AssetRecord: extracted = ( image_metadata(path, include_sha256=enable_sha256) @@ -332,8 +358,8 @@ def _asset_record( relative_path=_relative(run_dir, path), original_path=_relative(run_dir, path), original_filename=original_filename, - source_path=source_path, - source_url=source_url, + source_path=source_path if privacy.persist_sensitive_fields else None, + source_url=source_url if privacy.persist_sensitive_fields else None, mime_type=extracted.get("mime_type"), width=extracted.get("width"), height=extracted.get("height"), @@ -346,8 +372,14 @@ def _asset_record( ) -def _write_optional_json(run_dir: Path, path: Path, payload: Optional[Dict[str, Any]]) -> Optional[str]: - if payload is None: +def _write_optional_json( + run_dir: Path, + path: Path, + payload: Optional[Dict[str, Any]], + *, + privacy: ArtifactPrivacySettings, +) -> Optional[str]: + if payload is None or not privacy.persist_sensitive_fields: return None _write_json(path, payload) return _relative(run_dir, path) @@ -357,6 +389,45 @@ def _write_json(path: Path, payload: Dict[str, Any]) -> None: path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") +def _sanitize_mapping(payload: Dict[str, Any], *, privacy: ArtifactPrivacySettings) -> Dict[str, Any]: + return dict(payload) if privacy.persist_sensitive_fields else {} + + +def _sanitize_source_context( + context: RunSourceContext, + *, + privacy: ArtifactPrivacySettings, +) -> RunSourceContext: + if privacy.persist_sensitive_fields: + return context.model_copy(deep=True) + # Project/agent labels are kept because they are useful for browsing; + # user/channel/notes are stripped from the default dashboard-safe bundle. + return context.model_copy( + update={ + "source_user": None, + "source_channel": None, + "notes": None, + "metadata": {}, + }, + deep=True, + ) + + +def _sanitize_provider_trace(trace: ProviderTrace, *, privacy: ArtifactPrivacySettings) -> ProviderTrace: + if privacy.persist_sensitive_fields: + return trace.model_copy(deep=True) + return trace.model_copy( + update={ + "request_path": None, + "submit_payload_path": None, + "submit_response_path": None, + "final_status_path": None, + "metadata": {}, + }, + deep=True, + ) + + def write_run_notes(run: RunArtifact, *, manifest: Optional[RunManifest] = None) -> Path: path = Path(run.run_dir) / run.notes_path resolved_manifest = manifest or build_run_manifest(run) diff --git a/src/kie_api/clients/__init__.py b/src/kie_api/clients/__init__.py index 64d2f43..a177e7e 100644 --- a/src/kie_api/clients/__init__.py +++ b/src/kie_api/clients/__init__.py @@ -3,7 +3,9 @@ from .callbacks import ( CallbackEvent, build_callback_signature, + canonicalize_callback_payload, parse_callback_event, + verify_callback_request, verify_callback_signature, ) from .credits import CreditsClient @@ -15,11 +17,13 @@ __all__ = [ "CallbackEvent", "build_callback_signature", + "canonicalize_callback_payload", "CreditsClient", "DownloadClient", "StatusClient", "SubmitClient", "UploadClient", "parse_callback_event", + "verify_callback_request", "verify_callback_signature", ] diff --git a/src/kie_api/clients/callbacks.py b/src/kie_api/clients/callbacks.py index e916408..4592b00 100644 --- a/src/kie_api/clients/callbacks.py +++ b/src/kie_api/clients/callbacks.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import hashlib import hmac import time @@ -9,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field +from ..config import KieSettings from ..exceptions import CallbackVerificationError @@ -80,6 +82,46 @@ def verify_callback_signature( return hmac.compare_digest(expected, str(signature)) +def verify_callback_request( + payload: Dict[str, Any], + headers: Mapping[str, Any], + *, + secret: str, + settings: Optional[KieSettings] = None, + max_age_seconds: Optional[int] = None, + now: Optional[int] = None, +) -> CallbackEvent: + resolved_settings = settings or KieSettings() + resolved_max_age = ( + resolved_settings.callback_max_age_seconds if max_age_seconds is None else max_age_seconds + ) + if not verify_callback_signature( + payload, + headers, + secret=secret, + max_age_seconds=resolved_max_age, + now=now, + ): + raise CallbackVerificationError("Callback signature validation failed.") + + event = parse_callback_event(payload) + if not event.task_id: + raise CallbackVerificationError("Callback payload does not contain a usable task id.") + if payload.get("taskId") and str(payload.get("taskId")) != event.task_id: + raise CallbackVerificationError("Callback payload contains conflicting top-level and nested task ids.") + + for url in event.output_urls: + if not resolved_settings.is_trusted_callback_output_url(url): + raise CallbackVerificationError(f"Callback output URL host is not trusted: {url!r}") + return event + + +def canonicalize_callback_payload(payload: Dict[str, Any]) -> str: + """Return a deterministic compact JSON string for audit/debug use.""" + + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + def _extract_task_id(payload: Dict[str, Any]) -> Optional[str]: data = payload.get("data") or payload task_id = data.get("taskId") or data.get("task_id") or payload.get("taskId") diff --git a/src/kie_api/clients/download.py b/src/kie_api/clients/download.py index bca4999..74dda81 100644 --- a/src/kie_api/clients/download.py +++ b/src/kie_api/clients/download.py @@ -2,12 +2,14 @@ from __future__ import annotations +import os from pathlib import Path from typing import Optional import httpx from ..config import KieSettings +from ..exceptions import DownloadPolicyError from ..models import DownloadResult @@ -19,23 +21,70 @@ def __init__(self, settings: Optional[KieSettings] = None, http_client: Optional self.http_client = http_client or httpx.Client(timeout=self.settings.json_timeout()) def download_to_path(self, source_url: str, destination_path: str) -> DownloadResult: - destination = Path(destination_path) + self._validate_source_url(source_url) + destination = self._resolve_destination(destination_path) destination.parent.mkdir(parents=True, exist_ok=True) + temp_destination = destination.with_name(f".{destination.name}.part") - with self.http_client.stream("GET", source_url) as response: - response.raise_for_status() - with destination.open("wb") as handle: - for chunk in response.iter_bytes(): - handle.write(chunk) + try: + with self.http_client.stream("GET", source_url) as response: + response.raise_for_status() + content_length = _coerce_int(response.headers.get("Content-Length")) + self._validate_content_length(content_length) + bytes_written = 0 + with temp_destination.open("wb") as handle: + for chunk in response.iter_bytes(): + bytes_written += len(chunk) + if bytes_written > self.settings.download_max_bytes: + raise DownloadPolicyError( + f"download exceeded maximum allowed size of {self.settings.download_max_bytes} bytes" + ) + handle.write(chunk) + os.replace(temp_destination, destination) + except Exception: + temp_destination.unlink(missing_ok=True) + raise return DownloadResult( source_url=source_url, destination_path=str(destination), content_type=response.headers.get("Content-Type"), - content_length=_coerce_int(response.headers.get("Content-Length")), + content_length=content_length if content_length is not None else bytes_written, http_status=response.status_code, ) + def _validate_source_url(self, source_url: str) -> None: + parsed = httpx.URL(source_url) + if parsed.scheme != "https": + raise DownloadPolicyError("download source URLs must use https") + if parsed.username or parsed.password: + raise DownloadPolicyError("download source URLs must not contain userinfo") + if not self.settings.is_trusted_download_url(source_url): + raise DownloadPolicyError(f"download source host is not trusted: {parsed.host!r}") + + def _resolve_destination(self, destination_path: str) -> Path: + destination = Path(destination_path) + if ".." in destination.parts: + raise DownloadPolicyError("download destination must not contain parent-directory traversal") + if destination.exists() and destination.is_dir(): + raise DownloadPolicyError("download destination must be a file path, not a directory") + if self.settings.download_output_root: + allowed_root = Path(self.settings.download_output_root).resolve() + resolved_destination = destination.resolve(strict=False) + # When a wrapper exposes downloads to users, keep writes inside a single configured root. + if not _is_relative_to(resolved_destination, allowed_root): + raise DownloadPolicyError( + f"download destination must stay within configured root {allowed_root}" + ) + return resolved_destination + return destination + + def _validate_content_length(self, content_length: Optional[int]) -> None: + if content_length is not None and content_length > self.settings.download_max_bytes: + raise DownloadPolicyError( + f"download content length exceeds maximum allowed size of {self.settings.download_max_bytes} bytes" + ) + def _coerce_int(value: Optional[str]) -> Optional[int]: if value is None: @@ -44,3 +93,11 @@ def _coerce_int(value: Optional[str]) -> Optional[int]: return int(value) except ValueError: return None + + +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False diff --git a/src/kie_api/config.py b/src/kie_api/config.py index ccb1311..9e8d630 100644 --- a/src/kie_api/config.py +++ b/src/kie_api/config.py @@ -14,6 +14,11 @@ def _env_float(name: str, default: float) -> float: return float(value) if value is not None else default +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + return int(value) if value is not None else default + + def _env_csv(name: str, default: str) -> List[str]: value = os.getenv(name, default) return [item.strip() for item in value.split(",") if item.strip()] @@ -62,6 +67,27 @@ class KieSettings(BaseModel): "tempfile.redpandaai.co,kieai.redpandaai.co", ) ) + trusted_download_hosts: List[str] = Field( + default_factory=lambda: _env_csv( + "KIE_TRUSTED_DOWNLOAD_HOSTS", + "tempfile.redpandaai.co,kieai.redpandaai.co,tempfile.aiquickdraw.com", + ) + ) + download_max_bytes: int = Field( + default_factory=lambda: _env_int("KIE_DOWNLOAD_MAX_BYTES", 250_000_000) + ) + download_output_root: Optional[str] = Field( + default_factory=lambda: os.getenv("KIE_DOWNLOAD_OUTPUT_ROOT") + ) + callback_max_age_seconds: int = Field( + default_factory=lambda: _env_int("KIE_CALLBACK_MAX_AGE_SECONDS", 300) + ) + callback_trusted_output_hosts: List[str] = Field( + default_factory=lambda: _env_csv( + "KIE_CALLBACK_TRUSTED_OUTPUT_HOSTS", + "tempfile.redpandaai.co,kieai.redpandaai.co,tempfile.aiquickdraw.com", + ) + ) connect_timeout_seconds: float = Field( default_factory=lambda: _env_float("KIE_CONNECT_TIMEOUT_SECONDS", 10.0) ) @@ -139,10 +165,19 @@ def upload_timeout(self) -> httpx.Timeout: ) def is_trusted_uploaded_url(self, value: str) -> bool: + return self._url_host_is_trusted(value, self.trusted_uploaded_media_hosts) + + def is_trusted_download_url(self, value: str) -> bool: + return self._url_host_is_trusted(value, self.trusted_download_hosts) + + def is_trusted_callback_output_url(self, value: str) -> bool: + return self._url_host_is_trusted(value, self.callback_trusted_output_hosts) + + def _url_host_is_trusted(self, value: str, trusted_hosts: List[str]) -> bool: host = httpx.URL(value).host if not host: return False return any( host == trusted_host or host.endswith(f".{trusted_host}") - for trusted_host in self.trusted_uploaded_media_hosts + for trusted_host in trusted_hosts ) diff --git a/src/kie_api/exceptions.py b/src/kie_api/exceptions.py index df459d0..b95091d 100644 --- a/src/kie_api/exceptions.py +++ b/src/kie_api/exceptions.py @@ -29,6 +29,10 @@ class ArtifactProcessingError(KieApiError): """Raised when run artifact creation or derivative generation fails.""" +class DownloadPolicyError(KieApiError): + """Raised when a download request violates host, path, or size policy.""" + + class ProviderResponseError(KieApiError): """Raised when KIE returns an invalid or unsuccessful payload.""" diff --git a/tests/test_artifact_querying.py b/tests/test_artifact_querying.py index 334f927..ae1f89c 100644 --- a/tests/test_artifact_querying.py +++ b/tests/test_artifact_querying.py @@ -208,3 +208,24 @@ def test_custom_derivative_settings_change_index_hero_paths(tmp_path: Path) -> N assert entries[0].hero_thumb == "thumb/output_01.png" manifest = json.loads((Path(run.run_dir) / "manifest.json").read_text(encoding="utf-8")) assert manifest["hero_web"] == "web/output_01.jpg" + + +def test_load_run_index_skips_malformed_lines(tmp_path: Path) -> None: + output_root = tmp_path / "outputs" + output_root.mkdir(parents=True) + index_path = output_root / "index.jsonl" + index_path.write_text( + "\n".join( + [ + '{"run_id":"run_a","created_at":"2026-03-26T20:00:00+00:00","status":"succeeded","model_key":"nano-banana-2"}', + '{"broken":', + '{"run_id":"run_b","created_at":"2026-03-26T21:00:00+00:00","status":"failed","model_key":"nano-banana-pro"}', + ] + ) + + "\n", + encoding="utf-8", + ) + + entries = load_run_index(output_root) + + assert [entry.run_id for entry in entries] == ["run_a", "run_b"] diff --git a/tests/test_artifact_writer.py b/tests/test_artifact_writer.py index 75dda6d..35667b9 100644 --- a/tests/test_artifact_writer.py +++ b/tests/test_artifact_writer.py @@ -4,8 +4,19 @@ from PIL import Image -from kie_api import ArtifactDerivativeSettings, create_run_artifact -from kie_api.artifacts.models import ArtifactSource, PromptRecord, ProviderTrace, RunArtifactCreateRequest, RunSourceContext +from kie_api import ( + ArtifactDerivativeSettings, + ArtifactPrivacyMode, + ArtifactPrivacySettings, + create_run_artifact, +) +from kie_api.artifacts.models import ( + ArtifactSource, + PromptRecord, + ProviderTrace, + RunArtifactCreateRequest, + RunSourceContext, +) def test_create_run_artifact_writes_expected_bundle_for_image_run(tmp_path: Path) -> None: @@ -48,6 +59,11 @@ def test_create_run_artifact_writes_expected_bundle_for_image_run(tmp_path: Path final_status_response={"code": 200, "data": {"state": "success"}}, warnings=["Used test asset inputs."], tags=["portrait", "artifact-test"], + source_context=RunSourceContext( + project_name="artifact-tests", + source_user="alice", + source_channel="dashboard", + ), ), output_root=tmp_path / "outputs", ) @@ -63,9 +79,10 @@ def test_create_run_artifact_writes_expected_bundle_for_image_run(tmp_path: Path assert (run_dir / "original" / "output_01.png").exists() assert (run_dir / "web" / "output_01.webp").exists() assert (run_dir / "thumb" / "output_01.webp").exists() - assert (run_dir / "logs" / "submit_payload.json").exists() - assert (run_dir / "logs" / "submit_response.json").exists() - assert (run_dir / "logs" / "status_response_final.json").exists() + assert not (run_dir / "request.json").exists() + assert not (run_dir / "logs" / "submit_payload.json").exists() + assert not (run_dir / "logs" / "submit_response.json").exists() + assert not (run_dir / "logs" / "status_response_final.json").exists() manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) assert manifest["hero_output_path"] == "web/output_01.webp" @@ -78,7 +95,13 @@ def test_create_run_artifact_writes_expected_bundle_for_image_run(tmp_path: Path assert run_payload["outputs"][0]["web_path"] == "web/output_01.webp" assert run_payload["outputs"][0]["thumb_path"] == "thumb/output_01.webp" assert run_payload["outputs"][0]["bytes_original"] is not None + assert run_payload["inputs"][0]["source_path"] is None + assert run_payload["outputs"][0]["source_path"] is None + assert run_payload["source_context"]["source_user"] is None + assert run_payload["source_context"]["source_channel"] is None + assert run_payload["request_path"] is None assert run_payload["provider_trace"]["task_id"] == "task_123" + assert run_payload["provider_trace"]["submit_payload_path"] is None index_path = tmp_path / "outputs" / "index.jsonl" assert index_path.exists() @@ -86,6 +109,10 @@ def test_create_run_artifact_writes_expected_bundle_for_image_run(tmp_path: Path assert index_line["run_id"] == run.run_id assert index_line["hero_output"] == "web/output_01.webp" + notes_text = (run_dir / "notes.md").read_text(encoding="utf-8") + assert "Source user" not in notes_text + assert "Source channel" not in notes_text + def test_create_run_artifact_honors_custom_derivative_settings(tmp_path: Path) -> None: input_image = tmp_path / "input.png" @@ -126,3 +153,64 @@ def test_create_run_artifact_honors_custom_derivative_settings(tmp_path: Path) - assert output.sha256 is None assert output.derivatives[0].sha256 is None assert run.source_context.project_name == "artifact-tests" + + +def test_create_run_artifact_can_opt_into_full_internal_trace(tmp_path: Path) -> None: + input_image = tmp_path / "input.png" + output_image = tmp_path / "output.png" + Image.new("RGB", (640, 480), color="green").save(input_image) + Image.new("RGB", (640, 480), color="red").save(output_image) + + run = create_run_artifact( + RunArtifactCreateRequest( + status="succeeded", + model_key="nano-banana-2", + created_at=datetime(2026, 3, 26, 22, 30, 0, tzinfo=timezone.utc).isoformat(), + privacy=ArtifactPrivacySettings(mode=ArtifactPrivacyMode.FULL), + source_metadata={"team": "ops"}, + source_context=RunSourceContext( + project_name="artifact-tests", + source_user="tester", + source_channel="cli", + notes="keep me", + metadata={"scope": "internal"}, + ), + prompts=PromptRecord(raw="Test", final_used="Test"), + inputs=[ + ArtifactSource( + kind="image", + role="reference", + source_path=str(input_image), + source_url="https://example.com/input.png", + ) + ], + outputs=[ + ArtifactSource( + kind="image", + role="output", + source_path=str(output_image), + source_url="https://example.com/output.png", + ) + ], + request_payload={"prompt": "Test"}, + submit_payload={"model": "nano-banana-2"}, + submit_response={"code": 200}, + final_status_response={"code": 200, "data": {"state": "success"}}, + provider_trace=ProviderTrace(task_id="task_full"), + ), + output_root=tmp_path / "outputs", + ) + + run_dir = Path(run.run_dir) + assert (run_dir / "request.json").exists() + assert (run_dir / "logs" / "submit_payload.json").exists() + assert (run_dir / "logs" / "submit_response.json").exists() + assert (run_dir / "logs" / "status_response_final.json").exists() + + run_payload = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) + assert run_payload["inputs"][0]["source_path"] == str(input_image) + assert run_payload["inputs"][0]["source_url"] == "https://example.com/input.png" + assert run_payload["source_context"]["source_user"] == "tester" + assert run_payload["source_context"]["source_channel"] == "cli" + assert run_payload["source_metadata"] == {"team": "ops"} + assert run_payload["provider_trace"]["submit_payload_path"] == "logs/submit_payload.json" diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index a074236..dfc7663 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -2,9 +2,12 @@ from kie_api.clients.callbacks import ( build_callback_signature, + canonicalize_callback_payload, parse_callback_event, + verify_callback_request, verify_callback_signature, ) +from kie_api.config import KieSettings from kie_api.exceptions import CallbackVerificationError @@ -71,3 +74,58 @@ def test_verify_callback_signature_raises_on_missing_task_id() -> None: }, secret="top-secret", ) + + +def test_verify_callback_request_requires_trusted_output_urls() -> None: + payload = { + "data": { + "taskId": "task_123", + "status": "success", + "outputs": ["https://evil.example.com/out.mp4"], + } + } + headers = { + "X-Webhook-Timestamp": "1711111111", + "X-Webhook-Signature": build_callback_signature( + task_id="task_123", + timestamp="1711111111", + secret="top-secret", + ), + } + + with pytest.raises(CallbackVerificationError, match="not trusted"): + verify_callback_request( + payload, + headers, + secret="top-secret", + settings=KieSettings(callback_trusted_output_hosts=["tempfile.aiquickdraw.com"]), + max_age_seconds=600, + now=1711111111, + ) + + +def test_verify_callback_request_rejects_conflicting_task_ids() -> None: + payload = {"taskId": "outer_123", "data": {"taskId": "task_123"}} + headers = { + "X-Webhook-Timestamp": "1711111111", + "X-Webhook-Signature": build_callback_signature( + task_id="task_123", + timestamp="1711111111", + secret="top-secret", + ), + } + + with pytest.raises(CallbackVerificationError, match="conflicting"): + verify_callback_request( + payload, + headers, + secret="top-secret", + settings=KieSettings(), + max_age_seconds=600, + now=1711111111, + ) + + +def test_canonicalize_callback_payload_is_deterministic() -> None: + payload = {"b": 2, "a": {"y": 2, "x": 1}} + assert canonicalize_callback_payload(payload) == '{"a":{"x":1,"y":2},"b":2}' diff --git a/tests/test_download_client.py b/tests/test_download_client.py index 23446a3..4a21cf1 100644 --- a/tests/test_download_client.py +++ b/tests/test_download_client.py @@ -1,10 +1,12 @@ from pathlib import Path import httpx +import pytest from kie_api import download_output_file from kie_api.clients.download import DownloadClient from kie_api.config import KieSettings +from kie_api.exceptions import DownloadPolicyError from kie_api.models import DownloadResult @@ -36,6 +38,84 @@ def handler(request: httpx.Request) -> httpx.Response: assert result.content_length == len(body) +def test_download_client_rejects_untrusted_hosts(tmp_path: Path) -> None: + client = DownloadClient( + KieSettings(), + http_client=httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"ok"))), + ) + + with pytest.raises(DownloadPolicyError, match="not trusted"): + client.download_to_path( + "https://example.com/out.jpeg", + str(tmp_path / "downloads" / "out.jpeg"), + ) + + +def test_download_client_rejects_oversized_content_length(tmp_path: Path) -> None: + body = b"fake-image-bytes" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"Content-Type": "image/jpeg", "Content-Length": "50"}, + content=body, + ) + + client = DownloadClient( + KieSettings(download_max_bytes=10), + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DownloadPolicyError, match="content length exceeds"): + client.download_to_path( + "https://tempfile.aiquickdraw.com/out.jpeg", + str(tmp_path / "downloads" / "out.jpeg"), + ) + + +def test_download_client_deletes_partial_file_when_stream_exceeds_limit(tmp_path: Path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"Content-Type": "image/jpeg"}, + stream=httpx.ByteStream(b"1234567890"), + ) + + client = DownloadClient( + KieSettings(download_max_bytes=8), + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + destination = tmp_path / "downloads" / "out.jpeg" + + with pytest.raises(DownloadPolicyError, match="exceeded maximum allowed size"): + client.download_to_path( + "https://tempfile.aiquickdraw.com/out.jpeg", + str(destination), + ) + + assert not destination.exists() + assert not (destination.parent / ".out.jpeg.part").exists() + + +def test_download_client_enforces_optional_output_root(tmp_path: Path) -> None: + body = b"fake-image-bytes" + allowed_root = tmp_path / "allowed" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=body) + + client = DownloadClient( + KieSettings(download_output_root=str(allowed_root)), + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DownloadPolicyError, match="configured root"): + client.download_to_path( + "https://tempfile.aiquickdraw.com/out.jpeg", + str(tmp_path / "outside" / "out.jpeg"), + ) + + def test_public_download_helper_uses_download_client(tmp_path: Path, monkeypatch) -> None: destination = tmp_path / "downloaded.jpeg" From 934a2d320f0d719864f6e98889a0bda8ab3a6145 Mon Sep 17 00:00:00 2001 From: Gateway Date: Fri, 3 Apr 2026 11:52:53 +0700 Subject: [PATCH 2/3] Fix README links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 12e0cb5..15cf60c 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ Start with: - Your KIE key belongs in `KIE_API_KEY`. - Do not hardcode it in Python. - Do not commit `.env.live`. -- Use the tracked [`.env.example`](/Users/evilone/Documents/Development/Video-Image-APIs/kie-ai/kie_codex_bootstrap/.env.example) as the local template. +- Use the tracked [`.env.example`](.env.example) as the local template. - The repo ignores local env files so your personal key stays out of Git. ## What it includes @@ -479,4 +479,4 @@ Pricing note: ## License -This repo is licensed under the MIT License. See [LICENSE](/Users/evilone/Documents/Development/Video-Image-APIs/kie-ai/kie_codex_bootstrap/LICENSE). +This repo is licensed under the MIT License. See [LICENSE](LICENSE). From 4fd570260b7fb532b4e0cccaa3a6cce190fd2ac4 Mon Sep 17 00:00:00 2001 From: Gateway Date: Mon, 6 Apr 2026 10:23:46 +0700 Subject: [PATCH 3/3] Add Seedance 2.0 multimodal onboarding support --- .claude/agents/kie-model-onboarding.md | 29 +++ README.md | 20 ++ agent_skills/codex/README.md | 1 + agent_skills/codex/model-onboarding/SKILL.md | 106 +++++++++++ docs/AGENT_SKILLS.md | 8 + docs/LIBRARY_USAGE.md | 68 +++++++ docs/MODEL_ONBOARDING.md | 7 + docs/PRICING_AND_PREFLIGHT.md | 6 + skills/kie-model-onboarding.md | 32 ++++ specs/models/seedance_2_0.yaml | 116 ++++++++++++ src/kie_api/__init__.py | 2 + src/kie_api/adapters/market.py | 42 ++++- src/kie_api/enums.py | 8 + src/kie_api/models.py | 7 +- .../pricing/2026-03-26_site_pricing_page.yaml | 33 ++++ .../seedance_2_0_first_frame_v1/metadata.yaml | 18 ++ .../seedance_2_0_first_frame_v1/prompt.md | 9 + .../metadata.yaml | 19 ++ .../prompt.md | 9 + .../metadata.yaml | 21 +++ .../prompt.md | 12 ++ .../seedance_2_0_t2v_v1/metadata.yaml | 18 ++ .../seedance_2_0_t2v_v1/prompt.md | 10 + .../resources/specs/models/seedance_2_0.yaml | 116 ++++++++++++ src/kie_api/services/normalizer.py | 32 +++- src/kie_api/services/preparation.py | 2 + src/kie_api/services/pricing.py | 17 +- src/kie_api/services/pricing_refresh.py | 34 ++++ src/kie_api/services/prompt_enhancer.py | 68 ++++++- src/kie_api/services/validator.py | 176 +++++++++++++++++- tests/smoke/test_live_api_smoke.py | 44 +++++ tests/test_normalizer.py | 69 +++++++ tests/test_preparation.py | 51 ++++- tests/test_pricing_and_credit_guard.py | 27 +++ tests/test_pricing_refresh.py | 46 +++++ tests/test_prompt_enhancer.py | 33 ++++ tests/test_registry_loader.py | 4 +- tests/test_submit_client.py | 72 ++++++- tests/test_validator.py | 115 ++++++++++++ 39 files changed, 1494 insertions(+), 13 deletions(-) create mode 100644 .claude/agents/kie-model-onboarding.md create mode 100644 agent_skills/codex/model-onboarding/SKILL.md create mode 100644 skills/kie-model-onboarding.md create mode 100644 specs/models/seedance_2_0.yaml create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/metadata.yaml create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/prompt.md create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/metadata.yaml create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/prompt.md create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/metadata.yaml create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/prompt.md create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/metadata.yaml create mode 100644 src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/prompt.md create mode 100644 src/kie_api/resources/specs/models/seedance_2_0.yaml diff --git a/.claude/agents/kie-model-onboarding.md b/.claude/agents/kie-model-onboarding.md new file mode 100644 index 0000000..5c4a59d --- /dev/null +++ b/.claude/agents/kie-model-onboarding.md @@ -0,0 +1,29 @@ +--- +name: kie-model-onboarding +description: Use this subagent when the user wants to add a new Kie.ai image or video model into kie-api using the repo's existing spec, validation, prompt preset, and artifact workflow. +tools: Bash, Read, Grep, Glob +--- + +You are the Kie.ai model onboarding specialist for this repo. + +Use this workflow: +1. inspect the live Kie.ai model page and docs page +2. capture the exact request body shape and provider model string +3. decide how the model fits the existing task-shape system +4. update model specs and provenance +5. add prompt preset coverage for each supported request shape +6. add dry-run normalization, validation, and payload tests +7. run the cheapest practical live smoke path +8. verify artifacts and docs + +Important rules: +- do not invent provider fields +- if docs and live behavior disagree, prefer live behavior and document the mismatch +- do not treat every new video model like Kling if its shape is different +- if the model supports mutually-exclusive input scenarios, validate them explicitly + +Seedance 2.0 note: +- treat first-frame, first+last-frame, and multimodal reference as separate validated scenarios +- do not model it as Kling-style `multi_prompt` unless the API actually requires it + +If the user asks for help, explain that this subagent is for safely bringing new models online in kie-api, not for running a single generation request. diff --git a/README.md b/README.md index 15cf60c..50ef6bb 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Kie.ai is a marketplace-style API platform for creative AI models. Instead of on Examples of model families currently covered by this repo: - Nano Banana 2 - Nano Banana Pro +- Seedance 2.0 - Kling 2.6 text-to-video - Kling 2.6 image-to-video - Kling 3.0 text-to-video @@ -73,6 +74,12 @@ These descriptions are based on the current public Kie.ai model pages and live p - What it does: creates short videos from text or images with native audio support and structured text-to-video or image-to-video flows - Who it is by: Kling AI on Kie.ai +### Seedance 2.0 + +- Good at: multimodal guided video generation with first-frame, first+last-frame, and mixed image/video/audio references +- What it does: creates videos from plain prompts or reference-driven payloads, supports first and last frame guidance, multiple reference assets, optional audio generation, and reference-aware prompting +- Who it is by: ByteDance on Kie.ai + ### Kling 3.0 - Good at: higher-end cinematic video generation, stronger consistency, multi-shot direction, and image-to-video workflows @@ -148,6 +155,19 @@ Use one model’s output as another model’s input: - feed that image into Kling 3.0 image-to-video - store both runs and link them through artifacts +### 5. Multimodal reference video + +Use `seedance-2.0` when you need richer video guidance: +- plain text-to-video with no media +- one first-frame image +- first+last-frame image guidance +- multimodal reference video with reference images, videos, and audio + +Seedance uses role-aware media references in the runtime request model: +- `first_frame` +- `last_frame` +- `reference` + ## First live proof path The easiest way to prove your setup works is: diff --git a/agent_skills/codex/README.md b/agent_skills/codex/README.md index fc40cb2..68dbe2f 100644 --- a/agent_skills/codex/README.md +++ b/agent_skills/codex/README.md @@ -8,6 +8,7 @@ Included skills: - `nano-banana` - `kling-video` - `chain-image-to-video` +- `model-onboarding` To install them into your local Codex skills folder: diff --git a/agent_skills/codex/model-onboarding/SKILL.md b/agent_skills/codex/model-onboarding/SKILL.md new file mode 100644 index 0000000..e953d9a --- /dev/null +++ b/agent_skills/codex/model-onboarding/SKILL.md @@ -0,0 +1,106 @@ +--- +name: kie-model-onboarding +description: Onboard a new Kie.ai image or video model into kie-api using the repo's spec-first workflow. Use when adding a new Kie.ai model or mode, verifying docs vs live behavior, creating prompt presets, adding tests, and preparing the model for wrapper or dashboard use. +--- + +# Kie Model Onboarding + +Use this skill when bringing a new Kie.ai model online in `kie-api`. + +This is not a generation skill. It is a spec-first implementation workflow. + +## Primary goal + +Bring a new model online in a way that matches the existing system: +- model spec +- validation +- prompt preset coverage +- payload construction +- pricing/preflight +- live smoke verification +- artifact compatibility + +## Required workflow + +1. Read the live Kie.ai market page and docs page. +2. Capture the provider model string and exact request body shape. +3. Decide whether the model should be: + - a new standalone model key + - a family alias branch + - or a new request shape on an existing endpoint +4. Add or update the model spec under `specs/models/`. +5. Record field-level provenance. +6. Add prompt presets for every supported request shape. +7. Add dry-run tests for: + - normalization + - validation + - payload building + - preset resolution +8. Run the cheapest practical live smoke path. +9. Verify outputs, download flow, and artifacts. +10. Update docs. + +## Decision rules + +- Do not invent provider fields that the docs or live surface do not expose. +- If docs and live behavior disagree, prefer live provider truth and document the mismatch. +- If a model supports multiple mutually-exclusive input scenarios, model that explicitly in validation instead of hiding it inside passthrough options. +- Reuse existing request shapes when they truly fit. +- Add a new input-pattern concept only when the current patterns are not expressive enough. + +## Existing shape system + +Common task shapes already used in this repo: +- `prompt_only` +- `single_image` +- `first_last_frames` +- `image_edit` +- `motion_control` + +If a new model introduces a materially different shape, add it deliberately and document why. + +## Seedance 2.0 guidance + +For Seedance 2.0 specifically: +- provider model is `bytedance/seedance-2` +- it is one multimodal video endpoint with mutually-exclusive scenarios +- first-frame / first+last-frame / multimodal-reference should be treated as separate validated shapes +- do not model it as Kling-style `multi_prompt` unless the docs and live API explicitly require that +- multimodal reference support should account for: + - `reference_image_urls` + - `reference_video_urls` + - `reference_audio_urls` +- the current docs indicate: + - `resolution`: `480p | 720p` + - `aspect_ratio`: `16:9 | 4:3 | 1:1 | 3:4 | 9:16 | 21:9` + - `duration`: provider-controlled numeric seconds + - `return_last_frame` + - `generate_audio` + - `web_search` + +## Required files to update + +- `specs/models/*.yaml` +- packaged spec copy under `src/kie_api/resources/specs/models/` +- `specs/prompt_profiles/*` when new presets are needed +- packaged preset copy under `src/kie_api/resources/prompt_profiles/*` +- runtime request/validator/normalizer/payload code when shape support changes +- tests +- docs + +## Validation gates + +Before calling a model wrapper-ready, all of these should be true: +- dry-run normalization works +- validation catches impossible combinations +- payload tests pass +- prompt preset resolution is correct +- one live smoke path succeeds +- artifact output was inspected + +## Help + +If the user asks for help, explain: +- this skill is for adding or hardening models, not running generation +- the repo is spec-first +- the safest order is docs -> spec -> tests -> live smoke -> docs update diff --git a/docs/AGENT_SKILLS.md b/docs/AGENT_SKILLS.md index 2555cbe..b947679 100644 --- a/docs/AGENT_SKILLS.md +++ b/docs/AGENT_SKILLS.md @@ -40,6 +40,7 @@ Codex: - `kie-nano-banana` - `kie-kling-video` - `kie-chain-image-to-video` +- `kie-model-onboarding` Claude Code: - `.claude/agents/kie-find-latest-media.md` @@ -47,6 +48,7 @@ Claude Code: - `.claude/agents/kie-nano-banana.md` - `.claude/agents/kie-kling-video.md` - `.claude/agents/kie-chain-image-to-video.md` +- `.claude/agents/kie-model-onboarding.md` ## What these skills do @@ -78,6 +80,12 @@ Claude Code: - reads the artifact index and manifest layer - returns direct file paths so the user does not need to hunt +### `kie-model-onboarding` +- adds new Kie.ai image or video models to `kie-api` +- follows the repo's spec-first onboarding workflow +- covers request shape analysis, prompt presets, tests, and live smoke verification +- is the right workflow for models like Seedance 2.0 + ## Standard result contract Generation skills should return the actual media, not just a success message. diff --git a/docs/LIBRARY_USAGE.md b/docs/LIBRARY_USAGE.md index 3924a28..f06c053 100644 --- a/docs/LIBRARY_USAGE.md +++ b/docs/LIBRARY_USAGE.md @@ -129,6 +129,74 @@ print(validation.normalized_request.debug["frame_guidance_mode"]) Use `examples/dry_run_kling_frame_guidance.py` for a local dry-run check, including a path that can reuse the latest successful Nano Banana 2 artifact as the first frame. +## Seedance 2.0 multimodal reference shape + +Seedance 2.0 is the first model in `kie-api` that uses role-aware media references. + +Rules: +- keep media grouped by type: + - images in `images` + - videos in `videos` + - audios in `audios` +- add a `role` to each media item: + - `first_frame` + - `last_frame` + - `reference` +- the valid Seedance scenarios are: + - text-to-video with no media + - one `first_frame` image + - one `first_frame` plus one `last_frame` image + - multimodal reference media using `reference` roles +- first/last-frame mode and multimodal-reference mode are mutually exclusive + +Example: + +```python +from kie_api import normalize_request, resolve_prompt_context, validate_request +from kie_api.models import RawUserRequest + +request = RawUserRequest( + model_key="seedance-2.0", + prompt="Reference @image1 and @video1 for the fighter's design and motion language.", + images=[ + {"url": "https://example.com/character.png", "role": "reference"}, + {"url": "https://example.com/scene.png", "role": "reference"}, + ], + videos=[ + {"url": "https://example.com/motion.mp4", "role": "reference", "duration_seconds": 12}, + ], + audios=[ + {"url": "https://example.com/rhythm.mp3", "role": "reference"}, + ], + options={"duration": 8, "resolution": "720p", "generate_audio": True}, +) + +normalized = normalize_request(request) +context = resolve_prompt_context(normalized) +validation = validate_request(normalized) + +print(context.input_pattern) +print(context.resolved_preset_key) +print(context.rendered_system_prompt) +print(validation.state) +``` + +Seedance prompt context adds reference-aware render variables: +- `first_frame_present` +- `last_frame_present` +- `reference_image_count` +- `reference_video_count` +- `reference_audio_count` +- `reference_asset_guide` + +`reference_asset_guide` renders deterministic prompt tokens like: +- `@image1` +- `@image2` +- `@video1` +- `@audio1` + +Those tokens are for wrapper-side prompt enhancement and future dashboard/editor UX. They do not change the upload-first requirement. + ## Kling 3.0 multi-shot shape Kling 3.0 now supports the docs-aligned `multi_prompt` request shape in the runtime models. diff --git a/docs/MODEL_ONBOARDING.md b/docs/MODEL_ONBOARDING.md index c4edbb7..6212373 100644 --- a/docs/MODEL_ONBOARDING.md +++ b/docs/MODEL_ONBOARDING.md @@ -25,6 +25,7 @@ Typical request shapes: - `first_last_frames` - `image_edit` - `motion_control` +- `multimodal_reference` when a provider supports mixed image/video/audio guidance that is not equivalent to first/last-frame or edit mode A preset is ready when: - the template exists @@ -61,6 +62,12 @@ For advanced provider-specific shapes such as Kling 3.0 multi-shot mode: - validate cross-field rules explicitly - do not hide docs-only shape differences inside generic passthrough options +For multimodal video models such as Seedance 2.0: +- treat first-frame, first+last-frame, and multimodal-reference as mutually-exclusive validated scenarios if the provider documents them that way +- do not force them into a Kling-style multi-shot abstraction unless the provider request shape actually exposes shot arrays +- if multimodal references introduce mixed image/video/audio guidance, prefer a dedicated input-pattern binding over overloading existing `single_image` or `first_last_frames` logic +- use role-aware media references when the same endpoint needs to distinguish first-frame, last-frame, and general reference assets + Known current TODO: - Kling 3.0 `kling_elements` / element-reference support is documented by Kie.ai, but is not yet modeled in the runtime request types or upload flow here diff --git a/docs/PRICING_AND_PREFLIGHT.md b/docs/PRICING_AND_PREFLIGHT.md index 114a361..914ba92 100644 --- a/docs/PRICING_AND_PREFLIGHT.md +++ b/docs/PRICING_AND_PREFLIGHT.md @@ -114,6 +114,12 @@ That code is: - not authoritative - not a replacement for a verified billing API contract +Current example: +- Seedance 2.0 pricing is now derived from the public site pricing API rows for: + - `480p` vs `720p` + - `with video input` vs `no video input` +- the runtime turns that into a dry-run `pricing_variant` internally based on request shape, so wrappers do not need to send pricing-only fields + Manual candidate refresh: ```bash diff --git a/skills/kie-model-onboarding.md b/skills/kie-model-onboarding.md new file mode 100644 index 0000000..96dfe6a --- /dev/null +++ b/skills/kie-model-onboarding.md @@ -0,0 +1,32 @@ +# Skill: kie-model-onboarding + +Use this workflow when you want to add a new image or video model into `kie-api` without breaking the repo's existing abstractions. + +## What this skill is for + +Use this when you want: +- a new Kie.ai model spec +- prompt presets for a new model or mode +- validation for new provider-specific shapes +- payload tests and a live smoke path + +## Recommended order + +1. read the live market page and docs page +2. capture the exact request shape +3. decide whether it fits an existing task shape +4. add the spec and provenance +5. add prompt presets +6. add tests +7. run the cheapest live smoke +8. inspect artifacts and update docs + +## Seedance 2.0 note + +Seedance 2.0 should be modeled as a multimodal video endpoint with mutually-exclusive scenarios: +- text-to-video +- first-frame +- first+last-frame +- multimodal reference + +Do not assume Kling-style multi-shot or `multi_prompt` unless the docs or live API explicitly require it. diff --git a/specs/models/seedance_2_0.yaml b/specs/models/seedance_2_0.yaml new file mode 100644 index 0000000..6bb489d --- /dev/null +++ b/specs/models/seedance_2_0.yaml @@ -0,0 +1,116 @@ +key: seedance-2.0 +label: Seedance 2.0 Standard +family: market +provider_model: bytedance/seedance-2 +task_modes: + - text_to_video + - reference_to_video +inputs: + image: + required_min: 0 + required_max: 9 + video: + required_min: 0 + required_max: 3 + audio: + required_min: 0 + required_max: 3 +input_constraints: + image_formats: [jpg, jpeg, png, webp, bmp, gif] + image_max_mb: 30 + video_duration_max_seconds: 15 +options: + return_last_frame: + type: bool + default: false + generate_audio: + type: bool + default: false + resolution: + type: enum + allowed: [480p, 720p] + default: 720p + aspect_ratio: + type: enum + allowed: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'] + default: '16:9' + duration: + type: int_range + min: 4 + max: 15 + required: true + web_search: + type: bool + default: false +prompt: + required: true + max_chars: null + enhancement_supported: true + enhancement_default_policy: ask + default_profile_key: seedance_2_0_t2v_v1 + default_profile_keys_by_input_pattern: + prompt_only: seedance_2_0_t2v_v1 + single_image: seedance_2_0_first_frame_v1 + first_last_frames: seedance_2_0_first_last_frame_v1 + multimodal_reference: seedance_2_0_multimodal_reference_v1 +defaults: {} +transport: + endpoint_family: market + create_path: /api/v1/jobs/createTask + status_path: /api/v1/jobs/recordInfo + callback_supported: true +verification: + verified_from: + - live_page_api_surface + - docs_kie_secondary + verified_on: '2026-04-04' + verification_notes: + - Verified against the live https://kie.ai/seedance-2-0 playground field list and the https://docs.kie.ai/market/bytedance/seedance-2 API page on 2026-04-04. + - The live page exposes prompt, first_frame_url, last_frame_url, reference_image_urls, reference_video_urls, reference_audio_urls, return_last_frame, generate_audio, resolution, aspect_ratio, duration, and web_search. + - The live page shows image uploads supporting JPG, PNG, WEBP, BMP, and GIF up to 30MB each. + - The live UI states the total length of reference videos must not exceed 15 seconds and allows up to 3 reference videos. + - The prompt box examples reference uploaded assets with @imageN style tokens, so the SDK renders reference guidance metadata for wrapper prompts. + - The docs do not clearly publish authoritative pricing for Seedance 2.0 at this time, so pricing remains unknown until live verification. + - The docs and live page both present Seedance as one endpoint that supports text-only, first-frame, first+last-frame, and multimodal reference scenarios. + field_provenance: + key: inferred + label: inferred + family: inferred + provider_model: verified_docs + task_modes: inferred + inputs.image.required_min: inferred + inputs.image.required_max: verified_live + inputs.video.required_min: inferred + inputs.video.required_max: verified_live + inputs.audio.required_min: inferred + inputs.audio.required_max: verified_live + input_constraints.image_formats: verified_live + input_constraints.image_max_mb: verified_live + input_constraints.video_duration_max_seconds: verified_live + options.return_last_frame.type: inferred + options.return_last_frame.default: inferred + options.generate_audio.type: inferred + options.generate_audio.default: inferred + options.resolution.type: inferred + options.resolution.allowed: verified_live + options.resolution.default: inferred + options.aspect_ratio.type: inferred + options.aspect_ratio.allowed: verified_live + options.aspect_ratio.default: inferred + options.duration.type: inferred + options.duration.min: verified_docs + options.duration.max: verified_live + options.duration.required: inferred + options.web_search.type: inferred + options.web_search.default: inferred + prompt.required: verified_live + prompt.max_chars: unknown + prompt.enhancement_supported: inferred + prompt.enhancement_default_policy: inferred + prompt.default_profile_key: inferred + prompt.default_profile_keys_by_input_pattern: inferred + defaults: inferred + transport.endpoint_family: inferred + transport.create_path: verified_docs + transport.status_path: verified_docs + transport.callback_supported: verified_docs diff --git a/src/kie_api/__init__.py b/src/kie_api/__init__.py index 8a07388..9ec717f 100644 --- a/src/kie_api/__init__.py +++ b/src/kie_api/__init__.py @@ -50,6 +50,7 @@ from .clients import CreditsClient from .clients import DownloadClient from .config import KieSettings +from .enums import MediaRole from .fixtures import REQUEST_FIXTURES, RequestFixture, get_request_fixture from .models import ( AppliedDefault, @@ -125,6 +126,7 @@ "list_runs_by_tag", "load_run_artifact", "MediaReference", + "MediaRole", "MissingInput", "NormalizedRequest", "ObservedResponseFixture", diff --git a/src/kie_api/adapters/market.py b/src/kie_api/adapters/market.py index 77e82ce..2947034 100644 --- a/src/kie_api/adapters/market.py +++ b/src/kie_api/adapters/market.py @@ -5,7 +5,7 @@ import json from typing import Any, Dict, List, Optional -from ..enums import JobState, TaskMode +from ..enums import JobState, MediaRole, TaskMode from ..exceptions import ProviderResponseError from ..models import ( CreditBalanceResult, @@ -35,6 +35,16 @@ } +def _coerce_market_option_value(request: NormalizedRequest, provider_field: str, option_value: Any) -> Any: + if provider_field == "duration" and request.model_key.startswith("kling-2.6"): + if isinstance(option_value, bool): + return str(option_value).lower() + if isinstance(option_value, (int, float)) and int(option_value) == option_value: + return str(int(option_value)) + return str(option_value) + return option_value + + def build_market_submission_payload(request: NormalizedRequest, spec: ModelSpec) -> Dict[str, Any]: input_payload: Dict[str, Any] = {} @@ -42,7 +52,24 @@ def build_market_submission_payload(request: NormalizedRequest, spec: ModelSpec) if resolved_prompt: input_payload["prompt"] = resolved_prompt - if request.task_mode in {TaskMode.TEXT_TO_IMAGE, TaskMode.IMAGE_EDIT}: + if request.model_key == "seedance-2.0": + first_frame = _first_media_url(request.images, MediaRole.FIRST_FRAME) + last_frame = _first_media_url(request.images, MediaRole.LAST_FRAME) + reference_images = _media_urls(request.images, MediaRole.REFERENCE) + reference_videos = _media_urls(request.videos, MediaRole.REFERENCE) + reference_audios = _media_urls(request.audios, MediaRole.REFERENCE) + + if first_frame: + input_payload["first_frame_url"] = first_frame + if last_frame: + input_payload["last_frame_url"] = last_frame + if reference_images: + input_payload["reference_image_urls"] = reference_images + if reference_videos: + input_payload["reference_video_urls"] = reference_videos + if reference_audios: + input_payload["reference_audio_urls"] = reference_audios + elif request.task_mode in {TaskMode.TEXT_TO_IMAGE, TaskMode.IMAGE_EDIT}: if request.images: input_payload["image_input"] = [media.url or media.path for media in request.images] elif request.task_mode in {TaskMode.TEXT_TO_VIDEO, TaskMode.IMAGE_TO_VIDEO}: @@ -55,7 +82,7 @@ def build_market_submission_payload(request: NormalizedRequest, spec: ModelSpec) for option_name, option_value in request.options.items(): option_spec = spec.options.get(option_name) provider_field = option_spec.provider_field if option_spec and option_spec.provider_field else option_name - input_payload[provider_field] = option_value + input_payload[provider_field] = _coerce_market_option_value(request, provider_field, option_value) if request.multi_prompt: input_payload["multi_prompt"] = [ @@ -71,6 +98,15 @@ def build_market_submission_payload(request: NormalizedRequest, spec: ModelSpec) return payload +def _media_urls(media_list, role: MediaRole) -> List[str]: + return [item.url or item.path for item in media_list if item.role == role] + + +def _first_media_url(media_list, role: MediaRole) -> Optional[str]: + urls = _media_urls(media_list, role) + return urls[0] if urls else None + + def normalize_market_submission_response( payload: Dict[str, Any], provider_payload: Dict[str, Any], diff --git a/src/kie_api/enums.py b/src/kie_api/enums.py index 932eae8..1a44c96 100644 --- a/src/kie_api/enums.py +++ b/src/kie_api/enums.py @@ -16,11 +16,18 @@ class MediaType(StrEnum): AUDIO = "audio" +class MediaRole(StrEnum): + FIRST_FRAME = "first_frame" + LAST_FRAME = "last_frame" + REFERENCE = "reference" + + class TaskMode(StrEnum): TEXT_TO_IMAGE = "text_to_image" IMAGE_EDIT = "image_edit" TEXT_TO_VIDEO = "text_to_video" IMAGE_TO_VIDEO = "image_to_video" + REFERENCE_TO_VIDEO = "reference_to_video" MOTION_CONTROL = "motion_control" @@ -36,6 +43,7 @@ class PromptInputPattern(StrEnum): SINGLE_IMAGE = "single_image" FIRST_LAST_FRAMES = "first_last_frames" IMAGE_EDIT = "image_edit" + MULTIMODAL_REFERENCE = "multimodal_reference" MOTION_CONTROL = "motion_control" diff --git a/src/kie_api/models.py b/src/kie_api/models.py index cbc14b5..93a5546 100644 --- a/src/kie_api/models.py +++ b/src/kie_api/models.py @@ -11,6 +11,7 @@ from .enums import ( GuardDecision, JobState, + MediaRole, MediaType, PromptInputPattern, PromptPolicy, @@ -24,10 +25,12 @@ class MediaReference(BaseModel): model_config = ConfigDict(extra="forbid") media_type: MediaType + role: Optional[MediaRole] = None url: Optional[str] = None path: Optional[str] = None filename: Optional[str] = None mime_type: Optional[str] = None + duration_seconds: Optional[float] = None source: str = "remote" @model_validator(mode="after") @@ -41,7 +44,9 @@ def from_value(cls, value: Any, media_type: MediaType) -> "MediaReference": if isinstance(value, cls): return value if isinstance(value, dict): - return cls(media_type=media_type, **value) + payload = dict(value) + payload.setdefault("media_type", media_type) + return cls(**payload) if isinstance(value, Path): return cls(media_type=media_type, path=str(value), filename=value.name, source="local") if isinstance(value, str): diff --git a/src/kie_api/resources/pricing/2026-03-26_site_pricing_page.yaml b/src/kie_api/resources/pricing/2026-03-26_site_pricing_page.yaml index 14ca371..cc9e79c 100644 --- a/src/kie_api/resources/pricing/2026-03-26_site_pricing_page.yaml +++ b/src/kie_api/resources/pricing/2026-03-26_site_pricing_page.yaml @@ -153,3 +153,36 @@ rules: pro: 1.35 notes: - Observed from https://api.kie.ai/client/v1/model-pricing/page on 2026-03-26. + - model_key: seedance-2.0 + pricing_status: observed_site_pricing + billing_unit: second + provider: ByteDance + interface_type: video + anchor_url: https://kie.ai/seedance-2-0 + raw_credit_text: '19' + raw_usd_text: '0.095' + base_credits: 19 + base_cost_usd: 0.095 + multipliers: + duration: + '4': 4.0 + '5': 5.0 + '6': 6.0 + '7': 7.0 + '8': 8.0 + '9': 9.0 + '10': 10.0 + '11': 11.0 + '12': 12.0 + '13': 13.0 + '14': 14.0 + '15': 15.0 + pricing_variant: + 480p_no_video_input: 1.0 + 720p_no_video_input: 2.1578947368 + 480p_with_video_input: 0.6052631579 + 720p_with_video_input: 1.3157894737 + notes: + - Observed from https://api.kie.ai/client/v1/model-pricing/page on 2026-04-04. + - Seedance pricing is modeled with an internal pricing_variant derived from request resolution plus whether reference_video_urls are present. + - The site pricing API publishes separate rows for with video input and no video input; this rule maps those exactly for dry-run estimation. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/metadata.yaml b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/metadata.yaml new file mode 100644 index 0000000..96124a2 --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/metadata.yaml @@ -0,0 +1,18 @@ +key: seedance_2_0_first_frame_v1 +label: Seedance 2.0 First Frame Prompt Enhancer v1 +version: v1 +applies_to_models: + - seedance-2.0 +applies_to_task_modes: + - reference_to_video +applies_to_input_patterns: + - single_image +variables: + - user_prompt + - first_frame_present +rules: + - Preserve the first-frame composition and subject continuity. + - Describe motion that grows naturally out of the supplied starting frame. + - Do not refer to multimodal reference tokens unless they are actually present. +notes: + - Use this preset when Seedance 2.0 has exactly one first-frame image and no last frame or reference media. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/prompt.md b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/prompt.md new file mode 100644 index 0000000..47537c6 --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_frame_v1/prompt.md @@ -0,0 +1,9 @@ +You are preparing a Seedance 2.0 image-to-video prompt using one supplied first frame. + +User intent: +{{user_prompt}} + +Instructions: +- Treat the supplied first frame as the exact starting composition. +- Write a concise cinematic prompt that explains how the scene should animate forward from that frame. +- Emphasize motion, camera behavior, and continuity with the start image. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/metadata.yaml b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/metadata.yaml new file mode 100644 index 0000000..c403d92 --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/metadata.yaml @@ -0,0 +1,19 @@ +key: seedance_2_0_first_last_frame_v1 +label: Seedance 2.0 First+Last Frame Prompt Enhancer v1 +version: v1 +applies_to_models: + - seedance-2.0 +applies_to_task_modes: + - reference_to_video +applies_to_input_patterns: + - first_last_frames +variables: + - user_prompt + - first_frame_present + - last_frame_present +rules: + - Preserve continuity between the supplied first and last frame images. + - Describe motion that bridges the two endpoint frames cleanly. + - Avoid introducing visual changes that would contradict the ending frame. +notes: + - Use this preset when Seedance 2.0 has one first frame image and one last frame image. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/prompt.md b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/prompt.md new file mode 100644 index 0000000..ab18b3a --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_first_last_frame_v1/prompt.md @@ -0,0 +1,9 @@ +You are preparing a Seedance 2.0 first+last-frame video prompt. + +User intent: +{{user_prompt}} + +Instructions: +- The supplied images define the exact start and end states of the video. +- Write a cinematic prompt that bridges those two endpoint frames smoothly and plausibly. +- Emphasize subject continuity, camera movement, and the action arc between the frames. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/metadata.yaml b/src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/metadata.yaml new file mode 100644 index 0000000..24a5ef8 --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/metadata.yaml @@ -0,0 +1,21 @@ +key: seedance_2_0_multimodal_reference_v1 +label: Seedance 2.0 Multimodal Reference Prompt Enhancer v1 +version: v1 +applies_to_models: + - seedance-2.0 +applies_to_task_modes: + - reference_to_video +applies_to_input_patterns: + - multimodal_reference +variables: + - user_prompt + - reference_image_count + - reference_video_count + - reference_audio_count + - reference_asset_guide +rules: + - Preserve the user's subject, action, and style intent. + - Mention reference tokens only when they materially help the provider stay aligned to the supplied assets. + - Use the deterministic token guide exactly as given. +notes: + - Use this preset when Seedance 2.0 includes reference images, videos, or audio assets. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/prompt.md b/src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/prompt.md new file mode 100644 index 0000000..0c03e6d --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_multimodal_reference_v1/prompt.md @@ -0,0 +1,12 @@ +You are preparing a Seedance 2.0 multimodal-reference video prompt. + +User intent: +{{user_prompt}} + +Reference assets: +{{reference_asset_guide}} + +Instructions: +- Use the reference tokens exactly as listed above when they help preserve identity, motion language, scene design, or pacing. +- Keep the final prompt coherent and cinematic instead of turning it into a raw list of assets. +- If the user intent already clearly describes the sequence, use the reference tokens only to anchor the important parts. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/metadata.yaml b/src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/metadata.yaml new file mode 100644 index 0000000..e10b020 --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/metadata.yaml @@ -0,0 +1,18 @@ +key: seedance_2_0_t2v_v1 +label: Seedance 2.0 Text to Video Prompt Enhancer v1 +version: v1 +applies_to_models: + - seedance-2.0 +applies_to_task_modes: + - text_to_video +applies_to_input_patterns: + - prompt_only +variables: + - user_prompt + - duration +rules: + - Preserve the user's core scene and motion intent. + - Expand the prompt into concise cinematic video direction without inventing unsupported media references. + - Keep the final wording compatible with a single text-to-video request. +notes: + - Use this preset for plain Seedance 2.0 text-to-video requests with no first/last frames or reference assets. diff --git a/src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/prompt.md b/src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/prompt.md new file mode 100644 index 0000000..47f335e --- /dev/null +++ b/src/kie_api/resources/prompt_profiles/seedance_2_0_t2v_v1/prompt.md @@ -0,0 +1,10 @@ +You are preparing a production-ready Seedance 2.0 text-to-video prompt. + +User intent: +{{user_prompt}} + +Instructions: +- Preserve the user's subject, setting, and action. +- Write a single coherent cinematic video prompt with camera movement, motion clarity, lighting, and pacing. +- Do not mention reference assets because none were supplied. +- Keep the prompt direct and provider-ready. diff --git a/src/kie_api/resources/specs/models/seedance_2_0.yaml b/src/kie_api/resources/specs/models/seedance_2_0.yaml new file mode 100644 index 0000000..6bb489d --- /dev/null +++ b/src/kie_api/resources/specs/models/seedance_2_0.yaml @@ -0,0 +1,116 @@ +key: seedance-2.0 +label: Seedance 2.0 Standard +family: market +provider_model: bytedance/seedance-2 +task_modes: + - text_to_video + - reference_to_video +inputs: + image: + required_min: 0 + required_max: 9 + video: + required_min: 0 + required_max: 3 + audio: + required_min: 0 + required_max: 3 +input_constraints: + image_formats: [jpg, jpeg, png, webp, bmp, gif] + image_max_mb: 30 + video_duration_max_seconds: 15 +options: + return_last_frame: + type: bool + default: false + generate_audio: + type: bool + default: false + resolution: + type: enum + allowed: [480p, 720p] + default: 720p + aspect_ratio: + type: enum + allowed: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'] + default: '16:9' + duration: + type: int_range + min: 4 + max: 15 + required: true + web_search: + type: bool + default: false +prompt: + required: true + max_chars: null + enhancement_supported: true + enhancement_default_policy: ask + default_profile_key: seedance_2_0_t2v_v1 + default_profile_keys_by_input_pattern: + prompt_only: seedance_2_0_t2v_v1 + single_image: seedance_2_0_first_frame_v1 + first_last_frames: seedance_2_0_first_last_frame_v1 + multimodal_reference: seedance_2_0_multimodal_reference_v1 +defaults: {} +transport: + endpoint_family: market + create_path: /api/v1/jobs/createTask + status_path: /api/v1/jobs/recordInfo + callback_supported: true +verification: + verified_from: + - live_page_api_surface + - docs_kie_secondary + verified_on: '2026-04-04' + verification_notes: + - Verified against the live https://kie.ai/seedance-2-0 playground field list and the https://docs.kie.ai/market/bytedance/seedance-2 API page on 2026-04-04. + - The live page exposes prompt, first_frame_url, last_frame_url, reference_image_urls, reference_video_urls, reference_audio_urls, return_last_frame, generate_audio, resolution, aspect_ratio, duration, and web_search. + - The live page shows image uploads supporting JPG, PNG, WEBP, BMP, and GIF up to 30MB each. + - The live UI states the total length of reference videos must not exceed 15 seconds and allows up to 3 reference videos. + - The prompt box examples reference uploaded assets with @imageN style tokens, so the SDK renders reference guidance metadata for wrapper prompts. + - The docs do not clearly publish authoritative pricing for Seedance 2.0 at this time, so pricing remains unknown until live verification. + - The docs and live page both present Seedance as one endpoint that supports text-only, first-frame, first+last-frame, and multimodal reference scenarios. + field_provenance: + key: inferred + label: inferred + family: inferred + provider_model: verified_docs + task_modes: inferred + inputs.image.required_min: inferred + inputs.image.required_max: verified_live + inputs.video.required_min: inferred + inputs.video.required_max: verified_live + inputs.audio.required_min: inferred + inputs.audio.required_max: verified_live + input_constraints.image_formats: verified_live + input_constraints.image_max_mb: verified_live + input_constraints.video_duration_max_seconds: verified_live + options.return_last_frame.type: inferred + options.return_last_frame.default: inferred + options.generate_audio.type: inferred + options.generate_audio.default: inferred + options.resolution.type: inferred + options.resolution.allowed: verified_live + options.resolution.default: inferred + options.aspect_ratio.type: inferred + options.aspect_ratio.allowed: verified_live + options.aspect_ratio.default: inferred + options.duration.type: inferred + options.duration.min: verified_docs + options.duration.max: verified_live + options.duration.required: inferred + options.web_search.type: inferred + options.web_search.default: inferred + prompt.required: verified_live + prompt.max_chars: unknown + prompt.enhancement_supported: inferred + prompt.enhancement_default_policy: inferred + prompt.default_profile_key: inferred + prompt.default_profile_keys_by_input_pattern: inferred + defaults: inferred + transport.endpoint_family: inferred + transport.create_path: verified_docs + transport.status_path: verified_docs + transport.callback_supported: verified_docs diff --git a/src/kie_api/services/normalizer.py b/src/kie_api/services/normalizer.py index 9d3c1ba..2889b60 100644 --- a/src/kie_api/services/normalizer.py +++ b/src/kie_api/services/normalizer.py @@ -5,9 +5,9 @@ from copy import deepcopy from typing import Any, Dict, List, Tuple -from ..enums import PromptPolicy, TaskMode +from ..enums import MediaRole, PromptPolicy, TaskMode from ..exceptions import RequestNormalizationError -from ..models import AppliedDefault, NormalizedRequest, RawUserRequest +from ..models import AppliedDefault, MediaReference, NormalizedRequest, RawUserRequest from ..registry.loader import SpecRegistry from ..registry.models import ModelSpec @@ -94,6 +94,12 @@ def _resolve_task_mode(self, spec: ModelSpec, raw_request: RawUserRequest) -> Ta if spec.key in {"nano-banana-pro", "nano-banana-2"}: return TaskMode.IMAGE_EDIT if raw_request.images else TaskMode.TEXT_TO_IMAGE + if spec.key == "seedance-2.0": + if _seedance_has_reference_media(raw_request): + return TaskMode.REFERENCE_TO_VIDEO + if _seedance_frame_images(raw_request.images): + return TaskMode.REFERENCE_TO_VIDEO + return TaskMode.TEXT_TO_VIDEO raise RequestNormalizationError(f"unable to infer task mode for {spec.key}") @@ -151,3 +157,25 @@ def _resolve_prompt_policy(self, raw_request: RawUserRequest, spec: ModelSpec) - if raw_request.enhance is False: return PromptPolicy.OFF return spec.prompt.enhancement_default_policy + + +def _seedance_has_reference_media(raw_request: RawUserRequest) -> bool: + return bool( + _media_with_role(raw_request.images, MediaRole.REFERENCE) + or _media_with_role(raw_request.videos, MediaRole.REFERENCE) + or _media_with_role(raw_request.audios, MediaRole.REFERENCE) + ) + + +def _seedance_frame_images(images: List[MediaReference]) -> bool: + return bool( + _media_with_role(images, MediaRole.FIRST_FRAME) + or _media_with_role(images, MediaRole.LAST_FRAME) + ) + + +def _media_with_role( + media: List[MediaReference], + role: MediaRole, +) -> List[MediaReference]: + return [item for item in media if item.role == role] diff --git a/src/kie_api/services/preparation.py b/src/kie_api/services/preparation.py index 9883d59..0ab2ce1 100644 --- a/src/kie_api/services/preparation.py +++ b/src/kie_api/services/preparation.py @@ -146,9 +146,11 @@ def _prepare_media_list( prepared.append( MediaReference( media_type=media.media_type, + role=media.role, url=resolved_url, filename=upload_result.file_name or media.filename, mime_type=upload_result.mime_type or media.mime_type, + duration_seconds=media.duration_seconds, source="uploaded", ) ) diff --git a/src/kie_api/services/pricing.py b/src/kie_api/services/pricing.py index bd11779..e3fb437 100644 --- a/src/kie_api/services/pricing.py +++ b/src/kie_api/services/pricing.py @@ -56,7 +56,9 @@ def estimate( return self._estimate(model_key, options or {}) def estimate_request(self, request: NormalizedRequest) -> EstimatedCost: - return self._estimate(request.model_key, request.options) + options = dict(request.options) + options.update(_derive_request_pricing_options(request)) + return self._estimate(request.model_key, options) def _estimate(self, model_key: str, options: Dict[str, Any]) -> EstimatedCost: rule = self._rules.get(model_key) @@ -150,3 +152,16 @@ def _is_authoritative_pricing(pricing_status: str, source_kind: str) -> bool: "live_billing", } return pricing_status in authoritative_statuses or source_kind in authoritative_source_kinds + + +def _derive_request_pricing_options(request: NormalizedRequest) -> Dict[str, Any]: + derived: Dict[str, Any] = {} + + if request.model_key == "seedance-2.0": + resolution = _normalize_option_value(request.options.get("resolution") or "720p") + has_video_input = bool(request.videos) + derived["pricing_variant"] = ( + f"{resolution}_{'with_video_input' if has_video_input else 'no_video_input'}" + ) + + return derived diff --git a/src/kie_api/services/pricing_refresh.py b/src/kie_api/services/pricing_refresh.py index 2b9128c..a468059 100644 --- a/src/kie_api/services/pricing_refresh.py +++ b/src/kie_api/services/pricing_refresh.py @@ -241,6 +241,10 @@ def build_supported_model_snapshot( if kling_30_motion_rows: rules.append(_build_kling_30_motion_rule(kling_30_motion_rows)) + seedance_rows = _rows_with_phrase(capture.rows, "bytedance/seedance-2,") + if seedance_rows: + rules.append(_build_seedance_2_rule(seedance_rows)) + return PricingSnapshot( version=f"{released}-site-pricing-page", label="KIE site pricing page snapshot", @@ -425,6 +429,36 @@ def _build_kling_30_motion_rule(rows: List[PricingCatalogRow]) -> PricingRule: ) +def _build_seedance_2_rule(rows: List[PricingCatalogRow]) -> PricingRule: + base = _select_row(rows, "480p no video input") or rows[0] + return PricingRule( + model_key="seedance-2.0", + pricing_status="observed_site_pricing", + billing_unit="second", + provider="ByteDance", + interface_type="video", + anchor_url="https://kie.ai/seedance-2-0", + raw_credit_text=base.credit_price_text, + raw_usd_text=base.usd_price_text, + base_credits=base.credit_price, + base_cost_usd=base.usd_price, + multipliers={ + "duration": {str(value): float(value) for value in range(4, 16)}, + "pricing_variant": { + "480p_no_video_input": 1.0, + "720p_no_video_input": _ratio(_credit_for(rows, "720p no video input"), base.credit_price), + "480p_with_video_input": _ratio(_credit_for(rows, "480p with video input"), base.credit_price), + "720p_with_video_input": _ratio(_credit_for(rows, "720p with video input"), base.credit_price), + }, + }, + notes=[ + "Observed from https://api.kie.ai/client/v1/model-pricing/page on 2026-04-04.", + "Seedance pricing is modeled with an internal pricing_variant derived from request resolution plus whether reference_video_urls are present.", + "The site pricing API publishes separate rows for 'with video input' and 'no video input'; this rule maps those exactly for dry-run estimation.", + ], + ) + + def _coerce_float(value: object) -> Optional[float]: if value is None: return None diff --git a/src/kie_api/services/prompt_enhancer.py b/src/kie_api/services/prompt_enhancer.py index 7861dec..c5adc0d 100644 --- a/src/kie_api/services/prompt_enhancer.py +++ b/src/kie_api/services/prompt_enhancer.py @@ -6,7 +6,7 @@ from copy import deepcopy from typing import Callable, Optional -from ..enums import PromptInputPattern, PromptPolicy, PromptResolutionSource, TaskMode +from ..enums import MediaRole, PromptInputPattern, PromptPolicy, PromptResolutionSource, TaskMode from ..exceptions import PromptTemplateRenderError from ..models import ( NormalizedRequest, @@ -205,6 +205,14 @@ def _detect_input_pattern(request: NormalizedRequest) -> PromptInputPattern: return PromptInputPattern.MOTION_CONTROL if request.task_mode == TaskMode.IMAGE_EDIT: return PromptInputPattern.IMAGE_EDIT + if request.task_mode == TaskMode.REFERENCE_TO_VIDEO: + if _reference_media_count(request) > 0: + return PromptInputPattern.MULTIMODAL_REFERENCE + if _has_role(request.images, MediaRole.LAST_FRAME): + return PromptInputPattern.FIRST_LAST_FRAMES + if _has_role(request.images, MediaRole.FIRST_FRAME): + return PromptInputPattern.SINGLE_IMAGE + return PromptInputPattern.PROMPT_ONLY if request.task_mode == TaskMode.IMAGE_TO_VIDEO: if len(request.images) >= 2: return PromptInputPattern.FIRST_LAST_FRAMES @@ -216,6 +224,9 @@ def _build_template_context( request: NormalizedRequest, input_pattern: PromptInputPattern, ): + reference_images = _media_with_role(request.images, MediaRole.REFERENCE) + reference_videos = _media_with_role(request.videos, MediaRole.REFERENCE) + reference_audios = _media_with_role(request.audios, MediaRole.REFERENCE) return { "user_prompt": request.raw_prompt or request.prompt or "", "model_key": request.model_key, @@ -224,6 +235,16 @@ def _build_template_context( "image_count": str(len(request.images)), "video_count": str(len(request.videos)), "audio_count": str(len(request.audios)), + "first_frame_present": str(_has_role(request.images, MediaRole.FIRST_FRAME)).lower(), + "last_frame_present": str(_has_role(request.images, MediaRole.LAST_FRAME)).lower(), + "reference_image_count": str(len(reference_images)), + "reference_video_count": str(len(reference_videos)), + "reference_audio_count": str(len(reference_audios)), + "reference_asset_guide": _build_reference_asset_guide( + reference_images=reference_images, + reference_videos=reference_videos, + reference_audios=reference_audios, + ), } @@ -243,3 +264,48 @@ def replace(match: re.Match[str]) -> str: "prompt preset template referenced unresolved variables: " + ", ".join(sorted(set(missing))) ) return rendered + + +def _reference_media_count(request: NormalizedRequest) -> int: + return ( + len(_media_with_role(request.images, MediaRole.REFERENCE)) + + len(_media_with_role(request.videos, MediaRole.REFERENCE)) + + len(_media_with_role(request.audios, MediaRole.REFERENCE)) + ) + + +def _has_role(media_list, role: MediaRole) -> bool: + return any(item.role == role for item in media_list) + + +def _media_with_role(media_list, role: MediaRole): + return [item for item in media_list if item.role == role] + + +def _build_reference_asset_guide( + *, + reference_images, + reference_videos, + reference_audios, +) -> str: + lines = [] + + for index, item in enumerate(reference_images, start=1): + lines.append( + f"@image{index} -> reference image {index}" + + (f" ({item.filename})" if item.filename else "") + ) + for index, item in enumerate(reference_videos, start=1): + lines.append( + f"@video{index} -> reference video {index}" + + (f" ({item.filename})" if item.filename else "") + ) + for index, item in enumerate(reference_audios, start=1): + lines.append( + f"@audio{index} -> reference audio {index}" + + (f" ({item.filename})" if item.filename else "") + ) + + if not lines: + return "No reference assets provided." + return "\n".join(lines) diff --git a/src/kie_api/services/validator.py b/src/kie_api/services/validator.py index 876d439..e0d0bca 100644 --- a/src/kie_api/services/validator.py +++ b/src/kie_api/services/validator.py @@ -5,9 +5,10 @@ from copy import deepcopy from typing import Any, List -from ..enums import OptionType, ValidationState +from ..enums import MediaRole, OptionType, TaskMode, ValidationState from ..models import ( InvalidInput, + MediaReference, MissingInput, NormalizedRequest, ValidationMessage, @@ -112,6 +113,159 @@ def validate(self, request: NormalizedRequest) -> ValidationResult: ) ) + if request.model_key == "seedance-2.0": + first_frame_images = _media_with_role(normalized.images, MediaRole.FIRST_FRAME) + last_frame_images = _media_with_role(normalized.images, MediaRole.LAST_FRAME) + reference_images = _media_with_role(normalized.images, MediaRole.REFERENCE) + reference_videos = _media_with_role(normalized.videos, MediaRole.REFERENCE) + reference_audios = _media_with_role(normalized.audios, MediaRole.REFERENCE) + has_reference_media = bool(reference_images or reference_videos or reference_audios) + has_frame_mode = bool(first_frame_images or last_frame_images) + unlabeled_media = [ + item + for collection in (normalized.images, normalized.videos, normalized.audios) + for item in collection + if item.role is None + ] + + if unlabeled_media: + impossible_inputs.append( + InvalidInput( + field="media", + code="seedance_media_role_required", + message=( + "Seedance 2.0 media inputs must declare a role of first_frame, last_frame, or reference." + ), + received=[item.model_dump() for item in unlabeled_media], + ) + ) + + if normalized.task_mode == TaskMode.TEXT_TO_VIDEO and ( + normalized.images or normalized.videos or normalized.audios + ): + impossible_inputs.append( + InvalidInput( + field="task_mode", + code="seedance_text_to_video_cannot_include_media", + message=( + "Seedance 2.0 text-to-video requests cannot include first/last frame inputs or reference media." + ), + received=normalized.task_mode.value, + ) + ) + + if normalized.task_mode == TaskMode.REFERENCE_TO_VIDEO and not ( + normalized.images or normalized.videos or normalized.audios + ): + missing_inputs.append( + MissingInput( + field="media", + message=( + "Seedance 2.0 reference-to-video requests require a first frame, first+last frames, or reference media." + ), + ) + ) + + if has_frame_mode and has_reference_media: + impossible_inputs.append( + InvalidInput( + field="images", + code="seedance_frames_and_references_are_mutually_exclusive", + message=( + "Seedance 2.0 does not allow first/last frame guidance together with multimodal reference assets." + ), + received={ + "first_frame_count": len(first_frame_images), + "last_frame_count": len(last_frame_images), + "reference_image_count": len(reference_images), + "reference_video_count": len(reference_videos), + "reference_audio_count": len(reference_audios), + }, + ) + ) + + if last_frame_images and not first_frame_images: + impossible_inputs.append( + InvalidInput( + field="images", + code="seedance_last_frame_requires_first_frame", + message="Seedance 2.0 requires a first-frame image when a last-frame image is provided.", + received={"last_frame_count": len(last_frame_images)}, + ) + ) + + if len(first_frame_images) > 1: + impossible_inputs.append( + InvalidInput( + field="images", + code="too_many_first_frame_images", + message="Seedance 2.0 accepts at most one first-frame image.", + received=len(first_frame_images), + ) + ) + + if len(last_frame_images) > 1: + impossible_inputs.append( + InvalidInput( + field="images", + code="too_many_last_frame_images", + message="Seedance 2.0 accepts at most one last-frame image.", + received=len(last_frame_images), + ) + ) + + if len(reference_images) > 9: + impossible_inputs.append( + InvalidInput( + field="images", + code="too_many_reference_images", + message="Seedance 2.0 accepts at most 9 reference images.", + received=len(reference_images), + ) + ) + + if len(reference_videos) > 3: + impossible_inputs.append( + InvalidInput( + field="videos", + code="too_many_reference_videos", + message="Seedance 2.0 accepts at most 3 reference videos.", + received=len(reference_videos), + ) + ) + + if len(reference_audios) > 3: + impossible_inputs.append( + InvalidInput( + field="audios", + code="too_many_reference_audios", + message="Seedance 2.0 accepts at most 3 reference audios.", + received=len(reference_audios), + ) + ) + + video_total_duration = _sum_known_media_durations(reference_videos) + if video_total_duration is not None and video_total_duration > 15: + impossible_inputs.append( + InvalidInput( + field="videos", + code="reference_video_duration_limit_exceeded", + message="Seedance 2.0 reference videos must total 15 seconds or less.", + received=video_total_duration, + ) + ) + + if reference_audios: + warning_details.append( + ValidationMessage( + field="audios", + code="seedance_reference_audio_duration_unverified", + message=( + "Seedance 2.0 reference audio count is validated, but total reference audio duration still needs live verification." + ), + ) + ) + if request.model_key in {"kling-3.0-t2v", "kling-3.0-i2v"}: multi_shots_enabled = normalized.options.get("multi_shots") is True if normalized.multi_prompt and not multi_shots_enabled: @@ -310,3 +464,23 @@ def _invalid_option( self, field: str, code: str, message: str, received: Any ) -> InvalidInput: return InvalidInput(field=field, code=code, message=message, received=received) + + +def _media_with_role( + media: List[MediaReference], + role: MediaRole, +) -> List[MediaReference]: + return [item for item in media if item.role == role] + + +def _sum_known_media_durations(media: List[MediaReference]) -> int | None: + durations: List[int] = [] + for item in media: + duration_hint = item.duration_seconds + if duration_hint is None: + return None + try: + durations.append(int(duration_hint)) + except (TypeError, ValueError): + return None + return sum(durations) diff --git a/tests/smoke/test_live_api_smoke.py b/tests/smoke/test_live_api_smoke.py index 58ccd12..ae6904b 100644 --- a/tests/smoke/test_live_api_smoke.py +++ b/tests/smoke/test_live_api_smoke.py @@ -105,3 +105,47 @@ def test_live_status_smoke(live_submitted_task_id: str) -> None: assert result is not None assert result.task_id == live_submitted_task_id assert result.provider_status is not None + + +def test_live_seedance_first_frame_submit_smoke() -> None: + _require_live_smoke() + if os.getenv("KIE_SMOKE_ALLOW_SEEDANCE") != "1": + pytest.skip("Set KIE_SMOKE_ALLOW_SEEDANCE=1 to run Seedance 2.0 smoke tests.") + + source_url = os.getenv("KIE_SMOKE_SEEDANCE_FIRST_FRAME_URL") + if not source_url: + pytest.skip("Set KIE_SMOKE_SEEDANCE_FIRST_FRAME_URL for the Seedance 2.0 smoke test.") + + registry = load_registry() + settings = KieSettings.from_env() + validation = validate_request( + RawUserRequest( + model_key="seedance-2.0", + prompt=os.getenv( + "KIE_SMOKE_SEEDANCE_PROMPT", + "Animate from this first frame with a gentle cinematic push-in.", + ), + images=[{"url": source_url, "role": "first_frame"}], + options={"duration": 4, "resolution": "480p", "generate_audio": False}, + ), + registry, + ) + if validation.normalized_request is None or validation.state not in { + ValidationState.READY, + ValidationState.READY_WITH_DEFAULTS, + ValidationState.READY_WITH_WARNING, + }: + pytest.skip(f"Seedance smoke request did not validate cleanly: {validation.state}") + + prepared = prepare_request_for_submission(validation, registry=registry, settings=settings) + submission = submit_prepared_request(prepared, registry=registry, settings=settings) + status = wait_for_task( + submission.task_id, + settings=settings, + poll_interval_seconds=settings.wait_poll_interval_seconds, + timeout_seconds=min(settings.wait_timeout_seconds, 600.0), + ).final_status + + assert submission.task_id + assert status is not None + assert status.task_id == submission.task_id diff --git a/tests/test_normalizer.py b/tests/test_normalizer.py index 9ae797b..089bf0f 100644 --- a/tests/test_normalizer.py +++ b/tests/test_normalizer.py @@ -105,3 +105,72 @@ def test_normalizer_rejects_unsafe_family_inference_when_too_many_images() -> No ], ) ) + + +def test_normalizer_resolves_seedance_text_only_to_text_to_video() -> None: + normalizer = RequestNormalizer(load_registry()) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="a fox sprinting through a snowy forest", + options={"duration": 4}, + ) + ) + + assert normalized.task_mode == TaskMode.TEXT_TO_VIDEO + + +def test_normalizer_resolves_seedance_first_frame_to_reference_to_video() -> None: + normalizer = RequestNormalizer(load_registry()) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="animate from this starting pose", + images=[ + { + "url": "https://example.com/start.png", + "role": "first_frame", + } + ], + options={"duration": 4}, + ) + ) + + assert normalized.task_mode == TaskMode.REFERENCE_TO_VIDEO + + +def test_normalizer_resolves_seedance_first_last_frames_to_reference_to_video() -> None: + normalizer = RequestNormalizer(load_registry()) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="move from this opening frame to the final pose", + images=[ + {"url": "https://example.com/start.png", "role": "first_frame"}, + {"url": "https://example.com/end.png", "role": "last_frame"}, + ], + options={"duration": 6}, + ) + ) + + assert normalized.task_mode == TaskMode.REFERENCE_TO_VIDEO + + +def test_normalizer_resolves_seedance_reference_media_to_reference_to_video() -> None: + normalizer = RequestNormalizer(load_registry()) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="use the reference assets for character identity and pacing", + images=[{"url": "https://example.com/ref1.png", "role": "reference"}], + videos=[{"url": "https://example.com/ref1.mp4", "role": "reference"}], + audios=[{"url": "https://example.com/ref1.mp3", "role": "reference"}], + options={"duration": 8}, + ) + ) + + assert normalized.task_mode == TaskMode.REFERENCE_TO_VIDEO diff --git a/tests/test_preparation.py b/tests/test_preparation.py index 7f411b6..08d5889 100644 --- a/tests/test_preparation.py +++ b/tests/test_preparation.py @@ -5,7 +5,7 @@ from kie_api import build_submission_payload, prepare_request_for_submission from kie_api.config import KieSettings -from kie_api.enums import JobState, MediaType, TaskMode +from kie_api.enums import JobState, MediaRole, MediaType, TaskMode from kie_api.exceptions import RequestPreparationError from kie_api.models import MediaReference, NormalizedRequest, RawUserRequest, StatusResult, UploadResult from kie_api.registry.loader import load_registry @@ -152,6 +152,55 @@ def test_prepare_request_uploads_all_media_types_from_normalized_request() -> No assert prepared.normalized_request.audios[0].url.startswith("https://tempfile.redpandaai.co/") +def test_prepare_request_preserves_seedance_media_roles_and_payload_shape() -> None: + registry = load_registry() + upload_client = DummyUploadClient() + normalized = NormalizedRequest( + model_key="seedance-2.0", + provider_model="bytedance/seedance-2", + task_mode=TaskMode.REFERENCE_TO_VIDEO, + prompt="Use the first frame as the subject anchor.", + images=[ + MediaReference( + media_type=MediaType.IMAGE, + role=MediaRole.FIRST_FRAME, + path="/tmp/first.png", + filename="first.png", + ) + ], + videos=[ + MediaReference( + media_type=MediaType.VIDEO, + role=MediaRole.REFERENCE, + url="https://example.com/source/ref.mp4", + filename="ref.mp4", + duration_seconds=4.5, + ) + ], + options={"resolution": "480p", "aspect_ratio": "9:16", "duration": 5}, + ) + + prepared = prepare_request_for_submission( + normalized, + registry=registry, + settings=KieSettings(api_key="test-key"), + upload_client=upload_client, + ) + + assert prepared.normalized_request.images[0].role == MediaRole.FIRST_FRAME + assert prepared.normalized_request.videos[0].role == MediaRole.REFERENCE + assert prepared.normalized_request.videos[0].duration_seconds == 4.5 + + payload = build_submission_payload( + prepared.normalized_request, + registry=registry, + settings=KieSettings(api_key="test-key"), + ) + + assert payload["input"]["first_frame_url"].endswith("/first.png") + assert payload["input"]["reference_video_urls"][0].endswith("/ref.mp4") + + def test_build_submission_payload_rejects_non_uploaded_media_urls() -> None: registry = load_registry() diff --git a/tests/test_pricing_and_credit_guard.py b/tests/test_pricing_and_credit_guard.py index 70cc03b..a9eee72 100644 --- a/tests/test_pricing_and_credit_guard.py +++ b/tests/test_pricing_and_credit_guard.py @@ -5,6 +5,7 @@ from kie_api.services.credit_guard import CreditGuard from kie_api.services.preflight import PreflightService from kie_api.services.pricing import PricingRegistry +import pytest def test_pricing_registry_returns_snapshot_backed_estimate() -> None: @@ -110,3 +111,29 @@ def test_preflight_can_warn_without_confirmation_threshold() -> None: assert result.decision == GuardDecision.WARN assert result.can_submit is True + + +def test_pricing_registry_applies_seedance_request_shape_variant() -> None: + registry = PricingRegistry() + request = NormalizedRequest( + model_key="seedance-2.0", + provider_model="bytedance/seedance-2", + task_mode=TaskMode.REFERENCE_TO_VIDEO, + prompt="use these references for motion and scene pacing", + prompt_policy=PromptPolicy.OFF, + videos=[ + { + "media_type": "video", + "url": "https://example.com/ref.mp4", + "role": "reference", + } + ], + options={"duration": 8, "resolution": "720p"}, + ) + + estimate = registry.estimate_request(request) + + assert estimate.applied_multipliers["duration"] == 8.0 + assert estimate.applied_multipliers["pricing_variant"] == pytest.approx(25.0 / 19.0) + assert estimate.estimated_credits == pytest.approx(200.0) + assert estimate.estimated_cost_usd == pytest.approx(1.0) diff --git a/tests/test_pricing_refresh.py b/tests/test_pricing_refresh.py index b73c036..cfaddf8 100644 --- a/tests/test_pricing_refresh.py +++ b/tests/test_pricing_refresh.py @@ -193,6 +193,50 @@ def test_build_supported_model_snapshot_maps_live_pricing_rows() -> None: "discountRate": 9.09, "anchor": "https://kie.ai/kling-3-motion-control", }, + { + "modelDescription": "bytedance/seedance-2, 720p no video input", + "interfaceType": "video", + "provider": "ByteDance", + "creditPrice": "41", + "creditUnit": "per second", + "usdPrice": "0.205", + "falPrice": "0.25", + "discountRate": 18.0, + "anchor": None, + }, + { + "modelDescription": "bytedance/seedance-2, 720p with video input", + "interfaceType": "video", + "provider": "ByteDance", + "creditPrice": "25", + "creditUnit": "per second", + "usdPrice": "0.125", + "falPrice": "0.18", + "discountRate": 30.0, + "anchor": None, + }, + { + "modelDescription": "bytedance/seedance-2, 480p no video input", + "interfaceType": "video", + "provider": "ByteDance", + "creditPrice": "19", + "creditUnit": "per second", + "usdPrice": "0.095", + "falPrice": "0.12", + "discountRate": 20.0, + "anchor": None, + }, + { + "modelDescription": "bytedance/seedance-2, 480p with video input", + "interfaceType": "video", + "provider": "ByteDance", + "creditPrice": "11.5", + "creditUnit": "per second", + "usdPrice": "0.057", + "falPrice": "0.09", + "discountRate": 36.0, + "anchor": None, + }, ] rows = [ @@ -228,3 +272,5 @@ def test_build_supported_model_snapshot_maps_live_pricing_rows() -> None: assert rules["kling-2.6-t2v"].billing_unit == "video" assert rules["kling-3.0-t2v"].billing_unit == "second" assert rules["kling-3.0-motion"].multipliers["mode"]["1080p"] == 1.35 + assert rules["seedance-2.0"].billing_unit == "second" + assert rules["seedance-2.0"].multipliers["pricing_variant"]["720p_with_video_input"] == 25.0 / 19.0 diff --git a/tests/test_prompt_enhancer.py b/tests/test_prompt_enhancer.py index 83cb9be..87f3248 100644 --- a/tests/test_prompt_enhancer.py +++ b/tests/test_prompt_enhancer.py @@ -187,3 +187,36 @@ def test_prompt_enhancer_raises_for_unresolved_template_variables(tmp_path) -> N prompt_profile_key="broken_preset", ) ) + + +def test_prompt_enhancer_resolves_seedance_multimodal_reference_preset() -> None: + registry = load_registry() + enhancer = PromptEnhancer(registry) + + context = enhancer.resolve_context( + NormalizedRequest( + model_key="seedance-2.0", + provider_model="bytedance/seedance-2", + task_mode="reference_to_video", + prompt="use the reference assets for character identity and pacing", + raw_prompt="use the reference assets for character identity and pacing", + prompt_policy=PromptPolicy.PREVIEW, + images=[ + {"media_type": "image", "url": "https://example.com/ref1.png", "role": "reference", "filename": "ref1.png"}, + {"media_type": "image", "url": "https://example.com/ref2.png", "role": "reference", "filename": "ref2.png"}, + ], + videos=[ + {"media_type": "video", "url": "https://example.com/ref1.mp4", "role": "reference", "filename": "ref1.mp4"} + ], + audios=[ + {"media_type": "audio", "url": "https://example.com/ref1.mp3", "role": "reference", "filename": "ref1.mp3"} + ], + ) + ) + + assert str(context.input_pattern) == "multimodal_reference" + assert context.resolved_preset_key == "seedance_2_0_multimodal_reference_v1" + assert "@image1 -> reference image 1 (ref1.png)" in (context.rendered_system_prompt or "") + assert "@image2 -> reference image 2 (ref2.png)" in (context.rendered_system_prompt or "") + assert "@video1 -> reference video 1 (ref1.mp4)" in (context.rendered_system_prompt or "") + assert "@audio1 -> reference audio 1 (ref1.mp3)" in (context.rendered_system_prompt or "") diff --git a/tests/test_registry_loader.py b/tests/test_registry_loader.py index 93ff0e8..3a53ffe 100644 --- a/tests/test_registry_loader.py +++ b/tests/test_registry_loader.py @@ -12,10 +12,12 @@ def test_registry_loads_verified_model_specs() -> None: registry = load_registry() - assert len(registry.model_specs) == 7 + assert len(registry.model_specs) == 8 assert registry.get_model("nano-banana-pro").inputs["image"].required_max == 8 assert registry.get_model("nano-banana-2").inputs["image"].required_max == 14 assert registry.get_model("kling-3.0-i2v").inputs["image"].required_max == 2 + assert registry.get_model("seedance-2.0").provider_model == "bytedance/seedance-2" + assert registry.get_model("seedance-2.0").task_modes[-1] == "reference_to_video" assert registry.get_model("nano-banana-pro").options["resolution"].allowed == ["1K", "2K", "4K"] assert registry.get_model("nano-banana-2").options["output_format"].allowed == ["jpg", "png"] diff --git a/tests/test_submit_client.py b/tests/test_submit_client.py index c90aaed..ebce0d4 100644 --- a/tests/test_submit_client.py +++ b/tests/test_submit_client.py @@ -90,7 +90,7 @@ def test_submit_client_builds_kling_2_6_t2v_payload() -> None: assert payload["model"] == "kling-2.6/text-to-video" assert payload["input"]["sound"] is True assert payload["input"]["aspect_ratio"] == "16:9" - assert payload["input"]["duration"] == 5 + assert payload["input"]["duration"] == "5" def test_submit_client_builds_kling_2_6_i2v_payload() -> None: @@ -113,7 +113,7 @@ def test_submit_client_builds_kling_2_6_i2v_payload() -> None: "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/start.png" ] assert payload["input"]["sound"] is False - assert payload["input"]["duration"] == 10 + assert payload["input"]["duration"] == "10" def test_submit_client_builds_kling_3_video_payload() -> None: @@ -253,3 +253,71 @@ def test_submit_client_passes_through_docs_only_motion_background_source() -> No payload = client.build_payload(normalized) assert payload["input"]["background_source"] == "input_video" + + +def test_submit_client_builds_seedance_first_last_frame_payload() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + client = SubmitClient(KieSettings(api_key="test-key"), registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="bridge these two endpoint frames with a smooth motion arc", + images=[ + { + "url": "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/start.png", + "role": "first_frame", + }, + { + "url": "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/end.png", + "role": "last_frame", + }, + ], + options={"duration": 6, "resolution": "720p", "generate_audio": True}, + ) + ) + payload = client.build_payload(normalized) + + assert payload["model"] == "bytedance/seedance-2" + assert payload["input"]["first_frame_url"].endswith("start.png") + assert payload["input"]["last_frame_url"].endswith("end.png") + assert "reference_image_urls" not in payload["input"] + assert payload["input"]["generate_audio"] is True + + +def test_submit_client_builds_seedance_multimodal_reference_payload() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + client = SubmitClient(KieSettings(api_key="test-key"), registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="use the references for scene styling and pacing", + images=[ + {"url": "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/ref1.png", "role": "reference"}, + {"url": "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/ref2.png", "role": "reference"}, + ], + videos=[ + {"url": "https://kieai.redpandaai.co/kieai/183531/videos/user-uploads/ref.mov", "role": "reference"} + ], + audios=[ + {"url": "https://kieai.redpandaai.co/kieai/183531/audios/user-uploads/ref.mp3", "role": "reference"} + ], + options={"duration": 8, "web_search": False}, + ) + ) + payload = client.build_payload(normalized) + + assert payload["input"]["reference_image_urls"] == [ + "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/ref1.png", + "https://tempfile.redpandaai.co/kieai/183531/images/user-uploads/ref2.png", + ] + assert payload["input"]["reference_video_urls"] == [ + "https://kieai.redpandaai.co/kieai/183531/videos/user-uploads/ref.mov" + ] + assert payload["input"]["reference_audio_urls"] == [ + "https://kieai.redpandaai.co/kieai/183531/audios/user-uploads/ref.mp3" + ] + assert "first_frame_url" not in payload["input"] diff --git a/tests/test_validator.py b/tests/test_validator.py index b204665..9561a86 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -332,3 +332,118 @@ def test_validator_records_unknown_provider_passthrough_options_as_warnings() -> assert result.state == ValidationState.READY_WITH_WARNING assert result.warning_details[0].field == "mystery_flag" + + +def test_validator_rejects_seedance_last_frame_without_first_frame() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + validator = RequestValidator(registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="end on this pose", + images=[{"url": "https://example.com/end.png", "role": "last_frame"}], + options={"duration": 4}, + ) + ) + result = validator.validate(normalized) + + assert result.state == ValidationState.INVALID + assert any(item.code == "seedance_last_frame_requires_first_frame" for item in result.impossible_inputs) + + +def test_validator_rejects_seedance_frames_and_multimodal_references_together() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + validator = RequestValidator(registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="mix the first frame with other references", + images=[ + {"url": "https://example.com/start.png", "role": "first_frame"}, + {"url": "https://example.com/ref1.png", "role": "reference"}, + ], + options={"duration": 4}, + ) + ) + result = validator.validate(normalized) + + assert result.state == ValidationState.INVALID + assert any( + item.code == "seedance_frames_and_references_are_mutually_exclusive" + for item in result.impossible_inputs + ) + + +def test_validator_rejects_seedance_too_many_reference_images() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + validator = RequestValidator(registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="use all of these reference stills", + images=[ + {"url": f"https://example.com/ref{index}.png", "role": "reference"} + for index in range(10) + ], + options={"duration": 4}, + ) + ) + result = validator.validate(normalized) + + assert result.state == ValidationState.INVALID + assert any(item.code == "too_many_reference_images" for item in result.impossible_inputs) + + +def test_validator_rejects_seedance_reference_video_duration_sum_over_limit() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + validator = RequestValidator(registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="use these reference clips for motion language", + videos=[ + {"url": "https://example.com/ref1.mp4", "role": "reference", "duration_seconds": 10}, + {"url": "https://example.com/ref2.mp4", "role": "reference", "duration_seconds": 8}, + ], + options={"duration": 6}, + ) + ) + result = validator.validate(normalized) + + assert result.state == ValidationState.INVALID + assert any( + item.code == "reference_video_duration_limit_exceeded" + for item in result.impossible_inputs + ) + + +def test_validator_accepts_seedance_multimodal_reference_request() -> None: + registry = load_registry() + normalizer = RequestNormalizer(registry) + validator = RequestValidator(registry) + + normalized = normalizer.normalize( + RawUserRequest( + model_key="seedance-2.0", + prompt="use these references for the character, scene, and rhythm", + images=[ + {"url": "https://example.com/ref1.png", "role": "reference"}, + {"url": "https://example.com/ref2.png", "role": "reference"}, + ], + videos=[{"url": "https://example.com/ref1.mp4", "role": "reference", "duration_seconds": 12}], + audios=[{"url": "https://example.com/ref1.mp3", "role": "reference"}], + options={"duration": 8, "resolution": "720p"}, + ) + ) + result = validator.validate(normalized) + + assert result.state == ValidationState.READY_WITH_WARNING + assert any(item.code == "seedance_reference_audio_duration_unverified" for item in result.warning_details)