diff --git a/.env.example b/.env.example index d60f8987a..f384d4d72 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ FAL_KEY= # FLUX images, Google Veo video, Kling video, MiniMax video, Recraft images # Get one at https://fal.ai/dashboard/keys FAL_AI_API_KEY= # Alias for FAL_KEY (some SDKs/docs use this name); either one is read. +KIE_API_KEY= # KIE.AI Market gateway: Seedance/Kling video as an alternative to fal.ai + # Get one after signing in at https://kie.ai # --- Replicate --- REPLICATE_API_TOKEN= # Replicate-hosted video gen (seedance_replicate). Needed to make the diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 7ad51bad0..be9a6751f 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -227,6 +227,32 @@ No subscription — pure pay-as-you-go, no minimum spend. --- +### KIE.AI — Multi-Model Video Gateway + +> **Alternative to fal.ai for video generation.** One KIE.AI key unlocks Market video models such as Seedance and Kling through the unified task API. + +**Tools unlocked:** `kie_video` +**Env var:** `KIE_API_KEY` + +#### Setup + +1. Go to [kie.ai](https://kie.ai/) and sign in +2. Create/copy an API key from the KIE.AI API key management page +3. Add to `.env`: `KIE_API_KEY=your-key-here` + +#### Supported operations in OpenMontage + +- `text_to_video` via `bytedance/seedance-2`, `bytedance/seedance-2-fast`, and `kling/v3-turbo-text-to-video` +- `image_to_video` via `bytedance/seedance-2`, `bytedance/seedance-2-fast`, and `kling/v3-turbo-image-to-video` +- `reference_to_video` and `first_last_frame_to_video` via Seedance 2.0 variants +- Local image paths are uploaded through KIE's file upload API automatically + +#### Notes + +KIE.AI bills in credits and pricing can change by model. OpenMontage records KIE's `creditsConsumed` value in tool results when the API returns it, while `estimate_cost()` remains a rough planning estimate. + +--- + ### Kling Official — Direct API > **Official Kling path.** This is separate from `kling_video` via fal.ai: it uses Kling's official `Authorization: Bearer ` API, provider name `kling_official`, and direct Classic/Turbo/Omni task protocols. @@ -929,6 +955,7 @@ These tools require only FFmpeg or Python packages — no GPU, no API key. | **Google** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) | `google_tts`, `google_imagen`, `google_music`, `gemini_omni_video`, `veo_video` | Free tier (TTS) + paid | | **ElevenLabs** | `ELEVENLABS_API_KEY` | `elevenlabs_tts`, `music_gen` | Free tier + paid | | **fal.ai** | `FAL_KEY` | `flux_image`, `recraft_image`, `kling_video`, `veo_video`, `minimax_video` | Pay-as-you-go | +| **KIE.AI** | `KIE_API_KEY` | `kie_video` | Credit-based pay-as-you-go | | **Kling Official** | `KLING_API_KEY` | `kling_official_video`, `kling_official_image`, `kling_tts`, `kling_avatar`, `kling_lip_sync` | Pay-as-you-go | | **OpenAI** | `OPENAI_API_KEY` | `openai_tts`, `openai_image` | Paid only | | **xAI** | `XAI_API_KEY` | `grok_image`, `grok_video` | Paid only | @@ -949,7 +976,7 @@ How many providers cover each capability: | Capability | Cloud Providers | Local Providers | Free Options | |-----------|----------------|-----------------|--------------| | **Image Generation** | FLUX, Kling Official, Grok, Google Imagen, GPT Image 2, Recraft | Local Diffusion | Pexels, Pixabay (stock) | -| **Video Generation** | Grok, Kling Official, Kling via fal.ai, Runway, Veo, Gemini Omni, Higgsfield, MiniMax, HeyGen | WAN, Hunyuan, CogVideo, LTX | Pexels, Pixabay (stock) | +| **Video Generation** | Grok, Kling Official, Kling via fal.ai, KIE.AI, Runway, Veo, Gemini Omni, Higgsfield, MiniMax, HeyGen | WAN, Hunyuan, CogVideo, LTX | Pexels, Pixabay (stock) | | **Text-to-Speech** | ElevenLabs, Google TTS, Kling Official, OpenAI | Piper | Piper, Google free tier, ElevenLabs free tier | | **Music Generation** | ElevenLabs, Suno, Google Lyria | — | ElevenLabs free tier | | **Post-Production** | — | FFmpeg (compose, stitch, trim, mix, enhance, grade) | All free | @@ -968,7 +995,7 @@ A: FFmpeg + Node.js (both free, local). FFmpeg handles video assembly, audio mix A: Yes. The agent generates still images (via any image provider — even free stock from Pexels/Pixabay) and Remotion composes them into animated video with spring physics transitions, text cards, stat cards, and charts. This is the default path for explainer and animation pipelines when no video gen is configured. **Q: What's one low-friction way to get AI-generated images and video?** -A: fal.ai (`FAL_KEY`) is one pay-as-you-go option with broad single-key coverage. It unlocks FLUX images plus multiple video providers. No subscription — pay only for what you generate. +A: fal.ai (`FAL_KEY`) is one pay-as-you-go option with broad single-key coverage. KIE.AI (`KIE_API_KEY`) is an alternative video gateway for Seedance/Kling-style clips. Both are pay-as-you-go; configure whichever provider you already use. **Q: I have a GPU. What can I run locally for free?** A: Set `VIDEO_GEN_LOCAL_ENABLED=true` and install `diffusers`. You get WAN 2.1, Hunyuan, CogVideo, and LTX video generation plus Stable Diffusion image generation — all free, all offline. diff --git a/tests/tools/test_kie_video.py b/tests/tools/test_kie_video.py new file mode 100644 index 000000000..dad099cdc --- /dev/null +++ b/tests/tools/test_kie_video.py @@ -0,0 +1,97 @@ +from pathlib import Path + +from tools.base_tool import ToolStatus +from tools.video.kie_video import KieVideo + + +def test_kie_video_is_unavailable_without_api_key(monkeypatch): + monkeypatch.delenv("KIE_API_KEY", raising=False) + monkeypatch.delenv("KIE_AI_API_KEY", raising=False) + + assert KieVideo().get_status() == ToolStatus.UNAVAILABLE + + +def test_kie_video_builds_seedance_text_payload(monkeypatch): + tool = KieVideo() + + payload = tool._build_create_payload( + { + "prompt": "small workshop, warm light", + "operation": "text_to_video", + "aspect_ratio": "9:16", + "duration": "5", + "resolution": "720p", + "generate_audio": False, + }, + "bytedance/seedance-2", + "text_to_video", + { + "durations": [4, 5, 8, 10, 15], + "default_duration": 5, + "resolutions": ["480p", "720p", "1080p"], + "default_resolution": "720p", + }, + "test-key", + ) + + assert payload == { + "model": "bytedance/seedance-2", + "input": { + "prompt": "small workshop, warm light", + "duration": 5, + "resolution": "720p", + "aspect_ratio": "9:16", + "generate_audio": False, + "web_search": False, + "nsfw_checker": False, + }, + } + + +def test_kie_video_uploads_local_image_for_seedance_i2v(monkeypatch, tmp_path): + image_path = tmp_path / "frame.png" + image_path.write_bytes(b"fake-image") + monkeypatch.setattr(KieVideo, "_upload_file", staticmethod(lambda api_key, path, upload_path="openmontage": "https://cdn.example/frame.png")) + + payload = KieVideo()._build_create_payload( + { + "prompt": "animate this frame gently", + "operation": "image_to_video", + "reference_image_path": str(image_path), + }, + "bytedance/seedance-2", + "image_to_video", + { + "durations": [4, 5, 8, 10, 15], + "default_duration": 5, + "resolutions": ["480p", "720p", "1080p"], + "default_resolution": "720p", + }, + "test-key", + ) + + assert payload["input"]["first_frame_url"] == "https://cdn.example/frame.png" + + +def test_kie_video_extracts_upload_file_url_from_documented_shape(): + assert KieVideo._extract_uploaded_file_url( + {"code": 200, "data": {"fileUrl": "https://cdn.example/file.png"}} + ) == "https://cdn.example/file.png" + + +def test_kie_video_extracts_upload_download_url_from_live_shape(): + assert KieVideo._extract_uploaded_file_url( + { + "success": True, + "code": 200, + "data": { + "filePath": "kieai/11359937/openmontage/frame.png", + "downloadUrl": "https://tempfile.redpandaai.co/kieai/11359937/openmontage/frame.png", + }, + } + ) == "https://tempfile.redpandaai.co/kieai/11359937/openmontage/frame.png" + + +def test_kie_video_resolves_kling_model_for_operation(): + assert KieVideo._resolve_model("kling/v3-turbo-text-to-video", "image_to_video") == "kling/v3-turbo-image-to-video" + assert KieVideo._resolve_model("kling/v3-turbo-image-to-video", "text_to_video") == "kling/v3-turbo-text-to-video" diff --git a/tools/video/kie_video.py b/tools/video/kie_video.py new file mode 100644 index 000000000..8866ee1dd --- /dev/null +++ b/tools/video/kie_video.py @@ -0,0 +1,462 @@ +"""Video generation through KIE.AI Market API. + +KIE is a multi-model gateway similar in role to fal.ai. This tool gives +OpenMontage a non-fal route for Seedance/Kling-style clips while preserving the +same selector-driven provider contract used by the rest of the project. +""" + +from __future__ import annotations + +import json +import mimetypes +import os +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + + +KIE_API_BASE = "https://api.kie.ai" +KIE_UPLOAD_BASE = "https://kieai.redpandaai.co" + +KIE_MODEL_DEFAULTS: dict[str, dict[str, Any]] = { + "bytedance/seedance-2": { + "operations": {"text_to_video", "image_to_video", "reference_to_video", "first_last_frame_to_video"}, + "durations": [4, 5, 8, 10, 15], + "default_duration": 5, + "resolutions": ["480p", "720p", "1080p"], + "default_resolution": "720p", + "supports_audio": True, + }, + "bytedance/seedance-2-fast": { + "operations": {"text_to_video", "image_to_video", "reference_to_video", "first_last_frame_to_video"}, + "durations": [4, 5, 8, 10, 15], + "default_duration": 5, + "resolutions": ["480p", "720p", "1080p"], + "default_resolution": "720p", + "supports_audio": True, + }, + "kling/v3-turbo-text-to-video": { + "operations": {"text_to_video"}, + "durations": [5, 10], + "default_duration": 5, + "resolutions": ["720p", "1080p"], + "default_resolution": "720p", + "supports_audio": False, + }, + "kling/v3-turbo-image-to-video": { + "operations": {"image_to_video"}, + "durations": [5, 10], + "default_duration": 5, + "resolutions": ["720p", "1080p"], + "default_resolution": "720p", + "supports_audio": False, + }, +} + + +def _duration_to_int(value: Any, default: int) -> int: + if value is None or value == "": + return default + text = str(value).strip().lower().removesuffix("s") + return int(float(text)) + + +class KieVideo(BaseTool): + name = "kie_video" + version = "0.1.0" + tier = ToolTier.GENERATE + capability = "video_generation" + provider = "kie" + stability = ToolStability.EXPERIMENTAL + execution_mode = ExecutionMode.SYNC + determinism = Determinism.STOCHASTIC + runtime = ToolRuntime.API + + dependencies = [] + install_instructions = ( + "Set KIE_API_KEY to your KIE.AI API key.\n" + " Get one from https://kie.ai after signing in." + ) + agent_skills = ["ai-video-gen"] + + capabilities = [ + "text_to_video", + "image_to_video", + "reference_to_video", + "first_last_frame_to_video", + ] + supports = { + "text_to_video": True, + "image_to_video": True, + "reference_to_video": True, + "first_last_frame_to_video": True, + "native_audio": True, + "local_image_upload": True, + "seedance_2": True, + "kling": True, + } + best_for = [ + "non-fal.ai access to Seedance/Kling video models", + "image-to-video from local reference images via KIE upload", + "short vertical social clips with 5-15s model generations", + ] + not_good_for = ["offline generation", "long-form continuous video"] + fallback_tools = ["kling_video", "seedance_video", "minimax_video", "wan_video"] + + input_schema = { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": {"type": "string"}, + "operation": { + "type": "string", + "enum": ["text_to_video", "image_to_video", "reference_to_video", "first_last_frame_to_video"], + "default": "text_to_video", + }, + "model_variant": { + "type": "string", + "enum": list(KIE_MODEL_DEFAULTS.keys()), + "default": "bytedance/seedance-2", + }, + "duration": { + "type": ["string", "integer"], + "description": "Duration in seconds. Seedance supports 4-15s; Kling commonly supports 5/10s.", + }, + "aspect_ratio": { + "type": "string", + "enum": ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "adaptive"], + "default": "16:9", + }, + "resolution": { + "type": "string", + "enum": ["480p", "720p", "1080p"], + "default": "720p", + }, + "generate_audio": {"type": "boolean", "default": False}, + "web_search": {"type": "boolean", "default": False}, + "nsfw_checker": {"type": "boolean", "default": False}, + "image_url": {"type": "string", "description": "Reference image URL for image_to_video"}, + "image_path": {"type": "string", "description": "Local reference image path for image_to_video"}, + "reference_image_url": {"type": "string", "description": "Alias for image_url"}, + "reference_image_path": {"type": "string", "description": "Alias for image_path"}, + "reference_image_urls": {"type": "array", "items": {"type": "string"}}, + "reference_image_paths": {"type": "array", "items": {"type": "string"}}, + "first_frame_url": {"type": "string"}, + "first_frame_path": {"type": "string"}, + "last_frame_url": {"type": "string"}, + "last_frame_path": {"type": "string"}, + "callBackUrl": {"type": "string"}, + "poll_interval_seconds": {"type": "number", "default": 5}, + "timeout_seconds": {"type": "integer", "default": 900}, + "output_path": {"type": "string"}, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=512, vram_mb=0, disk_mb=500, network_required=True + ) + retry_policy = RetryPolicy(max_retries=2, retryable_errors=["rate_limit", "timeout"]) + idempotency_key_fields = ["prompt", "model_variant", "operation", "duration"] + side_effects = ["writes video file to output_path", "calls KIE.AI API"] + user_visible_verification = ["Watch generated clip for motion coherence and prompt adherence"] + + def _get_api_key(self) -> str | None: + return os.environ.get("KIE_API_KEY") or os.environ.get("KIE_AI_API_KEY") + + def get_status(self) -> ToolStatus: + if self._get_api_key(): + return ToolStatus.AVAILABLE + return ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + """Return a rough USD estimate. + + KIE bills in credits and changes model pricing over time. Keep this + deliberately conservative; provider-returned creditsConsumed is reported + in the final result when available. + """ + variant = inputs.get("model_variant", "bytedance/seedance-2") + meta = KIE_MODEL_DEFAULTS.get(variant, KIE_MODEL_DEFAULTS["bytedance/seedance-2"]) + duration = _duration_to_int(inputs.get("duration"), meta["default_duration"]) + if "fast" in variant: + return 0.06 * (duration / 5) + if "kling" in variant: + return 0.12 * (duration / 5) + return 0.10 * (duration / 5) + + def estimate_runtime(self, inputs: dict[str, Any]) -> float: + variant = inputs.get("model_variant", "bytedance/seedance-2") + if "fast" in variant: + return 45.0 + return 90.0 + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + api_key = self._get_api_key() + if not api_key: + return ToolResult(success=False, error="KIE_API_KEY not set. " + self.install_instructions) + + import requests + + start = time.time() + operation = inputs.get("operation", "text_to_video") + model = self._resolve_model(inputs.get("model_variant", "bytedance/seedance-2"), operation) + meta = KIE_MODEL_DEFAULTS.get(model) + if not meta: + return ToolResult(success=False, error=f"Unsupported KIE model_variant: {model}") + if operation not in meta["operations"]: + return ToolResult(success=False, error=f"KIE model {model} does not support operation {operation}") + + try: + payload = self._build_create_payload(inputs, model, operation, meta, api_key) + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + submit_resp = requests.post( + f"{KIE_API_BASE}/api/v1/jobs/createTask", + headers=headers, + json=payload, + timeout=30, + ) + submit_resp.raise_for_status() + submit_data = submit_resp.json() + if submit_data.get("code") != 200: + return ToolResult(success=False, error=f"KIE task creation failed: {submit_data}") + task_id = submit_data.get("data", {}).get("taskId") + if not task_id: + return ToolResult(success=False, error=f"KIE response missing taskId: {submit_data}") + + record = self._poll_task(api_key, task_id, inputs) + result_json = record.get("resultJson") or "{}" + result_data = json.loads(result_json) if isinstance(result_json, str) else result_json + result_urls = result_data.get("resultUrls") or [] + if not result_urls: + return ToolResult(success=False, error=f"KIE task succeeded but returned no resultUrls: {record}") + video_url = result_urls[0] + + output_path = Path(inputs.get("output_path", "kie_output.mp4")) + output_path.parent.mkdir(parents=True, exist_ok=True) + download_resp = requests.get(video_url, timeout=180) + if download_resp.status_code >= 400: + # Some KIE URLs require a temporary download URL conversion. + video_url = self._get_download_url(api_key, video_url) + download_resp = requests.get(video_url, timeout=180) + download_resp.raise_for_status() + output_path.write_bytes(download_resp.content) + except Exception as e: + return ToolResult(success=False, error=f"KIE video generation failed: {e}") + + from tools.video._shared import probe_output + + probed = probe_output(output_path) + credits = record.get("creditsConsumed") + return ToolResult( + success=True, + data={ + "provider": "kie", + "model": model, + "task_id": task_id, + "prompt": inputs["prompt"], + "operation": operation, + "aspect_ratio": inputs.get("aspect_ratio", "16:9"), + "output": str(output_path), + "output_path": str(output_path), + "source_url": video_url, + "format": "mp4", + "credits_consumed": credits, + **probed, + }, + artifacts=[str(output_path)], + cost_usd=self.estimate_cost(inputs), + duration_seconds=round(time.time() - start, 2), + model=model, + ) + + @staticmethod + def _resolve_model(model_variant: str, operation: str) -> str: + if model_variant == "kling/v3-turbo-text-to-video" and operation == "image_to_video": + return "kling/v3-turbo-image-to-video" + if model_variant == "kling/v3-turbo-image-to-video" and operation == "text_to_video": + return "kling/v3-turbo-text-to-video" + return model_variant + + def _build_create_payload( + self, + inputs: dict[str, Any], + model: str, + operation: str, + meta: dict[str, Any], + api_key: str, + ) -> dict[str, Any]: + duration = _duration_to_int(inputs.get("duration"), meta["default_duration"]) + if duration not in meta["durations"]: + # KIE docs accept Seedance 4-15s, while some model pages list examples. + # Clamp only for Kling-style limited variants; allow the general Seedance range. + if model.startswith("bytedance/seedance") and 4 <= duration <= 15: + pass + else: + allowed = ", ".join(str(d) for d in meta["durations"]) + raise ValueError(f"duration={duration} is not supported by {model}; allowed: {allowed}") + + resolution = inputs.get("resolution") or meta["default_resolution"] + if resolution not in meta["resolutions"]: + raise ValueError(f"resolution={resolution} is not supported by {model}") + + input_payload: dict[str, Any] = { + "prompt": inputs["prompt"], + "duration": duration if model.startswith("bytedance/seedance") else str(duration), + "resolution": resolution, + } + + if model.startswith("bytedance/seedance"): + input_payload["aspect_ratio"] = inputs.get("aspect_ratio", "16:9") + input_payload["generate_audio"] = bool(inputs.get("generate_audio", False)) + input_payload["web_search"] = bool(inputs.get("web_search", False)) + input_payload["nsfw_checker"] = bool(inputs.get("nsfw_checker", False)) + elif model.startswith("kling/") and operation == "image_to_video": + # Kling v3 turbo image-to-video docs only require prompt/image_urls/duration/resolution. + pass + + first_frame = self._normalize_url_or_upload( + api_key, inputs.get("first_frame_url"), inputs.get("first_frame_path") + ) + last_frame = self._normalize_url_or_upload( + api_key, inputs.get("last_frame_url"), inputs.get("last_frame_path") + ) + image_url = self._normalize_url_or_upload( + api_key, + inputs.get("image_url") or inputs.get("reference_image_url"), + inputs.get("image_path") or inputs.get("reference_image_path"), + ) + reference_urls = list(inputs.get("reference_image_urls") or []) + for path in inputs.get("reference_image_paths") or []: + reference_urls.append(self._upload_file(api_key, path, upload_path="openmontage")) + + if operation == "image_to_video": + if model.startswith("kling/"): + urls = reference_urls or ([image_url] if image_url else []) + if not urls: + raise ValueError("image_to_video with KIE Kling requires image_url/image_path/reference_image_urls") + input_payload["image_urls"] = urls + else: + frame = first_frame or image_url + if not frame: + raise ValueError("image_to_video requires image_url/image_path/first_frame_url/first_frame_path") + input_payload["first_frame_url"] = frame + elif operation == "first_last_frame_to_video": + if not first_frame or not last_frame: + raise ValueError("first_last_frame_to_video requires first_frame and last_frame") + input_payload["first_frame_url"] = first_frame + input_payload["last_frame_url"] = last_frame + elif operation == "reference_to_video": + if image_url: + reference_urls.insert(0, image_url) + if not reference_urls: + raise ValueError("reference_to_video requires at least one reference image URL/path") + input_payload["reference_image_urls"] = reference_urls[:9] + + payload: dict[str, Any] = {"model": model, "input": input_payload} + if inputs.get("callBackUrl"): + payload["callBackUrl"] = inputs["callBackUrl"] + return payload + + @staticmethod + def _normalize_url_or_upload(api_key: str, url: str | None, path: str | None) -> str | None: + if url: + return url + if path: + return KieVideo._upload_file(api_key, path, upload_path="openmontage") + return None + + @staticmethod + def _upload_file(api_key: str, path_str: str, upload_path: str = "openmontage") -> str: + import requests + + path = Path(path_str) + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + with path.open("rb") as f: + resp = requests.post( + f"{KIE_UPLOAD_BASE}/api/file-stream-upload", + headers={"Authorization": f"Bearer {api_key}"}, + files={"file": (path.name, f, mime_type)}, + data={"uploadPath": upload_path, "fileName": path.name}, + timeout=120, + ) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 200 and not data.get("success"): + raise RuntimeError(f"KIE file upload failed: {data}") + file_url = KieVideo._extract_uploaded_file_url(data) + if not file_url: + raise RuntimeError(f"KIE file upload response missing fileUrl/downloadUrl: {data}") + return file_url + + @staticmethod + def _extract_uploaded_file_url(upload_response: dict[str, Any]) -> str | None: + """Return the usable URL from KIE file-upload response variants. + + KIE docs show `data.fileUrl`, while the live file-stream API may return + `data.downloadUrl` plus `data.filePath`. Market video endpoints accept a + normal HTTPS URL, so prefer `fileUrl` when present and fall back to the + temporary `downloadUrl` returned by the live API. + """ + payload = upload_response.get("data") or {} + if not isinstance(payload, dict): + return None + return payload.get("fileUrl") or payload.get("downloadUrl") + + @staticmethod + def _poll_task(api_key: str, task_id: str, inputs: dict[str, Any]) -> dict[str, Any]: + import requests + + deadline = time.time() + int(inputs.get("timeout_seconds", 900)) + interval = float(inputs.get("poll_interval_seconds", 5)) + headers = {"Authorization": f"Bearer {api_key}"} + last_record: dict[str, Any] | None = None + while time.time() < deadline: + resp = requests.get( + f"{KIE_API_BASE}/api/v1/jobs/recordInfo", + headers=headers, + params={"taskId": task_id}, + timeout=30, + ) + resp.raise_for_status() + payload = resp.json() + record = payload.get("data") or {} + last_record = record + state = record.get("state") + if state == "success": + return record + if state == "fail": + raise RuntimeError(f"KIE task failed: {record.get('failCode')} {record.get('failMsg')}") + time.sleep(interval) + interval = min(interval * 1.25, 30.0) + raise TimeoutError(f"KIE task {task_id} timed out. Last status: {last_record}") + + @staticmethod + def _get_download_url(api_key: str, url: str) -> str: + import requests + + resp = requests.post( + f"{KIE_API_BASE}/api/v1/common/download-url", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"url": url}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 200: + raise RuntimeError(f"KIE download-url conversion failed: {data}") + return data["data"]