Add official Kling API providers

This commit is contained in:
xucailiang
2026-07-07 14:56:40 +08:00
parent 0c202b507a
commit 7c5dfdd31a
43 changed files with 9470 additions and 16 deletions

View File

@@ -0,0 +1,120 @@
"""Contract tests for the Kling official avatar provider."""
from __future__ import annotations
import base64
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.avatar.kling_avatar import KlingAvatar
from tools.avatar.talking_head import TalkingHead
from tools.tool_registry import registry
def test_registry_discovers_kling_avatar(monkeypatch):
monkeypatch.delenv("KLING_API_KEY", raising=False)
registry.clear()
registry.discover("tools")
tool = registry.get("kling_avatar")
assert tool is not None
assert tool.capability == "avatar"
assert tool.provider == "kling_official"
def test_avatar_schema_and_local_tool_are_distinct():
tool = KlingAvatar()
assert "anyOf" in tool.input_schema
assert "allOf" in tool.input_schema
assert "kling-official" in tool.agent_skills
assert "avatar-video" in tool.agent_skills
assert tool.runtime.value == "api"
assert TalkingHead().provider == "sadtalker"
assert TalkingHead().runtime.value == "local_gpu"
def test_avatar_payload_uses_image_and_audio_paths(tmp_path):
image_path = tmp_path / "avatar.png"
audio_path = tmp_path / "voice.mp3"
image_path.write_bytes(b"image")
audio_path.write_bytes(b"audio")
request = KlingAvatar()._build_request(
{
"image_path": str(image_path),
"audio_path": str(audio_path),
"prompt": "warm presenter, subtle head motion",
"mode": "pro",
"callback_url": "https://example.com/kling/callback",
}
)
assert request["path"] == "/v1/videos/avatar/image2video"
assert request["payload"]["image"] == base64.b64encode(b"image").decode("ascii")
assert request["payload"]["sound_file"] == base64.b64encode(b"audio").decode("ascii")
assert request["payload"]["mode"] == "pro"
assert request["payload"]["callback_url"] == "https://example.com/kling/callback"
assert request["audio_source"]["type"] == "sound_file"
def test_avatar_requires_image_and_audio():
tool = KlingAvatar()
try:
tool._build_request({"audio_id": "audio-a"})
except ValueError as exc:
assert "image_url or image_path" in str(exc)
else:
raise AssertionError("Kling avatar must require an image")
try:
tool._build_request({"image_url": "https://example.com/avatar.png"})
except ValueError as exc:
assert "requires audio_id" in str(exc)
else:
raise AssertionError("Kling avatar must require audio input")
def test_execute_downloads_avatar_video(monkeypatch, tmp_path):
class FakeClient:
def create_classic_task(self, path, payload):
self.path = path
self.payload = payload
return "avatar-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
assert result_key == "videos"
return [{"url": "https://example.com/avatar.mp4"}]
def download(self, url, output_path):
output_path.write_bytes(b"video")
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.avatar.kling_avatar.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.avatar.kling_avatar.probe_output", lambda path: {"duration_seconds": 5.0})
result = KlingAvatar().execute(
{
"image_url": "https://example.com/avatar.png",
"audio_id": "audio-a",
"output_path": str(tmp_path / "avatar.mp4"),
}
)
assert result.success
assert result.data["provider"] == "kling_official"
assert result.data["task_id"] == "avatar-task-1"
assert result.data["duration_seconds"] == 5.0
assert Path(result.artifacts[0]).read_bytes() == b"video"
assert result.cost_usd > 0
def test_avatar_cost_estimate_is_not_zero():
tool = KlingAvatar()
base = tool.estimate_cost({"image_url": "x", "audio_id": "a"})
pro = tool.estimate_cost({"image_url": "x", "audio_id": "a", "mode": "pro"})
assert base > 0
assert pro > base
assert tool.dry_run({"image_url": "x", "audio_id": "a"})["cost_estimate_confidence"] == "low"

View File

@@ -0,0 +1,233 @@
"""Contract tests for the Kling official lip-sync provider."""
from __future__ import annotations
import base64
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.avatar.kling_lip_sync import KlingLipSync
from tools.avatar.lip_sync import LipSync
from tools.tool_registry import registry
def test_registry_discovers_kling_lip_sync(monkeypatch):
monkeypatch.delenv("KLING_API_KEY", raising=False)
registry.clear()
registry.discover("tools")
tool = registry.get("kling_lip_sync")
assert tool is not None
assert tool.capability == "avatar"
assert tool.provider == "kling_official"
def test_lip_sync_schema_and_local_tool_are_distinct():
tool = KlingLipSync()
assert "kling-official" in tool.agent_skills
assert "avatar-video" in tool.agent_skills
assert tool.runtime.value == "api"
assert LipSync().provider == "wav2lip"
assert LipSync().runtime.value == "local_gpu"
def test_identify_face_payload_and_local_video_rejection():
tool = KlingLipSync()
request = tool._build_identify_request({"video_url": "https://example.com/source.mp4"})
assert request["path"] == "/v1/videos/identify-face"
assert request["payload"] == {"video_url": "https://example.com/source.mp4"}
try:
tool._build_identify_request({"video_path": "/tmp/local.mp4"})
except ValueError as exc:
assert "local video paths cannot be silently uploaded" in str(exc)
else:
raise AssertionError("Local video paths must not be silently uploaded")
def test_advanced_lip_sync_payload_uses_face_and_audio_path(tmp_path):
audio_path = tmp_path / "voice.mp3"
audio_path.write_bytes(b"audio")
request = KlingLipSync()._build_advanced_request(
{
"session_id": "session-a",
"face_id": "face-a",
"audio_path": str(audio_path),
"callback_url": "https://example.com/kling/callback",
}
)
assert request["path"] == "/v1/videos/advanced-lip-sync"
assert request["payload"]["session_id"] == "session-a"
assert request["payload"]["face_choose"] == [{"face_id": "face-a"}]
assert request["payload"]["sound_file"] == base64.b64encode(b"audio").decode("ascii")
assert request["payload"]["callback_url"] == "https://example.com/kling/callback"
def test_identify_face_execute_writes_faces_artifact(monkeypatch, tmp_path):
class FakeClient:
def post(self, path, payload):
assert path == "/v1/videos/identify-face"
return {
"code": 0,
"data": {
"session_id": "session-a",
"faces": [{"face_id": "face-a", "bbox": [0, 0, 100, 100]}],
},
}
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.avatar.kling_lip_sync.KlingClient", lambda: FakeClient())
artifact_path = tmp_path / "faces.json"
result = KlingLipSync().execute(
{
"operation": "identify_face",
"video_url": "https://example.com/source.mp4",
"faces_artifact_path": str(artifact_path),
}
)
assert result.success
assert result.data["session_id"] == "session-a"
data = json.loads(artifact_path.read_text())
assert data["provider"] == "kling_official"
assert data["face_count"] == 1
def test_full_lip_sync_requires_confirmation_for_multiple_faces(monkeypatch, tmp_path):
class FakeClient:
def post(self, path, payload):
return {
"code": 0,
"data": {
"session_id": "session-a",
"faces": [
{"face_id": "face-small", "bbox": [0, 0, 50, 50]},
{"face_id": "face-large", "bbox": [0, 0, 200, 200]},
],
},
}
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.avatar.kling_lip_sync.KlingClient", lambda: FakeClient())
result = KlingLipSync().execute(
{
"operation": "full_lip_sync",
"video_url": "https://example.com/source.mp4",
"audio_id": "audio-a",
"faces_artifact_path": str(tmp_path / "faces.json"),
}
)
assert not result.success
assert result.data["requires_face_selection"] is True
assert len(result.data["faces"]) == 2
assert "Multiple faces detected" in result.error
assert Path(result.artifacts[0]).is_file()
def test_full_lip_sync_auto_selects_largest_face_and_downloads(monkeypatch, tmp_path):
class FakeClient:
def post(self, path, payload):
return {
"code": 0,
"data": {
"session_id": "session-a",
"faces": [
{"face_id": "face-small", "bbox": [0, 0, 50, 50]},
{"face_id": "face-large", "bbox": [0, 0, 200, 200]},
],
},
}
def create_classic_task(self, path, payload):
self.path = path
self.payload = payload
assert payload["face_choose"] == [{"face_id": "face-large"}]
return "lip-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
assert result_key == "videos"
return [{"url": "https://example.com/lip.mp4"}]
def download(self, url, output_path):
output_path.write_bytes(b"video")
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.avatar.kling_lip_sync.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.avatar.kling_lip_sync.probe_output", lambda path: {"duration_seconds": 4.0})
result = KlingLipSync().execute(
{
"operation": "full_lip_sync",
"video_url": "https://example.com/source.mp4",
"audio_id": "audio-a",
"auto_select_face": True,
"output_path": str(tmp_path / "lip.mp4"),
}
)
assert result.success
assert result.data["task_id"] == "lip-task-1"
assert result.data["face_selection"]["selection_method"] == "auto_selected"
assert result.data["face_choose"] == [{"face_id": "face-large"}]
assert Path(result.artifacts[0]).read_bytes() == b"video"
faces_artifact = next(Path(path) for path in result.artifacts if Path(path).name == "kling_lip_sync_faces.json")
assert json.loads(faces_artifact.read_text())["selection"]["selection_method"] == "auto_selected"
assert result.cost_usd > 0
def test_auto_select_face_area_avoids_position_inflation():
tool = KlingLipSync()
assert tool._face_area({"bbox": [10, 20, 110, 220]}) == 100 * 200
assert tool._face_area({"bbox": [10, 20, 100, 200]}) == 90 * 180
assert tool._face_area({"box": {"width": 80, "height": 90}}) == 80 * 90
def test_advanced_lip_sync_execute_downloads_video(monkeypatch, tmp_path):
class FakeClient:
def create_classic_task(self, path, payload):
assert path == "/v1/videos/advanced-lip-sync"
assert payload["face_choose"] == [{"face_id": "face-a"}]
return "lip-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
return [{"video_url": "https://example.com/lip.mp4"}]
def download(self, url, output_path):
output_path.write_bytes(b"video")
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.avatar.kling_lip_sync.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.avatar.kling_lip_sync.probe_output", lambda path: {"duration_seconds": 4.0})
result = KlingLipSync().execute(
{
"operation": "advanced_lip_sync",
"session_id": "session-a",
"face_id": "face-a",
"audio_id": "audio-a",
"output_path": str(tmp_path / "lip.mp4"),
}
)
assert result.success
assert result.data["provider"] == "kling_official"
assert result.data["task_id"] == "lip-task-1"
assert result.data["duration_seconds"] == 4.0
assert result.cost_usd > 0
def test_lip_sync_cost_estimate_is_not_zero():
tool = KlingLipSync()
assert tool.estimate_cost({"operation": "identify_face"}) > 0
assert tool.estimate_cost({"operation": "advanced_lip_sync"}) > tool.estimate_cost({"operation": "identify_face"})
assert tool.dry_run({"operation": "advanced_lip_sync"})["cost_estimate_confidence"] == "low"

View File

@@ -0,0 +1,179 @@
"""Contract tests for the Kling official shared client and schema snapshot."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools._kling.client import KlingClient
from tools._kling.errors import KlingAPIError, is_retryable_kling_error
from tools._kling.schemas import DEFAULT_API_BASE_URL
class FakeResponse:
def __init__(self, data=None, status_code=200, content=b"data", text=""):
self._data = data if data is not None else {"code": 0}
self.status_code = status_code
self.content = content
self.text = text
def json(self):
if isinstance(self._data, Exception):
raise self._data
return self._data
class FakeSession:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def post(self, url, **kwargs):
self.calls.append(("post", url, kwargs))
return self.responses.pop(0)
def get(self, url, **kwargs):
self.calls.append(("get", url, kwargs))
return self.responses.pop(0)
def test_missing_api_key_header_error(monkeypatch):
monkeypatch.delenv("KLING_API_KEY", raising=False)
client = KlingClient(session=FakeSession([]))
with pytest.raises(KlingAPIError) as exc:
_ = client.headers
assert "KLING_API_KEY" in str(exc.value)
def test_headers_use_bearer_api_key(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
session = FakeSession([FakeResponse({"code": 0, "data": {"ok": True}})])
client = KlingClient(session=session)
client.post("/v1/test", {"prompt": "x"})
headers = session.calls[0][2]["headers"]
assert headers["Authorization"] == "Bearer test-key"
assert headers["Content-Type"] == "application/json"
def test_default_and_env_base_url(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.delenv("KLING_API_BASE_URL", raising=False)
assert KlingClient().base_url == DEFAULT_API_BASE_URL
monkeypatch.setenv("KLING_API_BASE_URL", "https://api-beijing.klingai.com")
assert KlingClient().base_url == "https://api-beijing.klingai.com"
def test_business_error_preserves_code_message_request_id(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
session = FakeSession([FakeResponse({"code": 1200, "message": "bad parameter", "request_id": "req-1"})])
client = KlingClient(session=session, max_retries=0)
with pytest.raises(KlingAPIError) as exc:
client.post("/v1/videos/text2video", {})
assert exc.value.code == 1200
assert exc.value.message == "bad parameter"
assert exc.value.request_id == "req-1"
def test_1303_retryable_message_mentions_concurrency(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
session = FakeSession([FakeResponse({"code": 1303, "message": "parallel task over resource pack limit"})])
client = KlingClient(session=session, max_retries=0)
with pytest.raises(KlingAPIError) as exc:
client.post("/v1/videos/text2video", {})
assert is_retryable_kling_error(exc.value)
assert "并发/资源包限制" in exc.value.message
def test_classic_create_and_poll_parse_result_paths(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
session = FakeSession(
[
FakeResponse({"code": 0, "data": {"task_id": "task-1"}}),
FakeResponse(
{
"code": 0,
"data": {
"task_status": "succeed",
"task_result": {"videos": [{"url": "https://example.com/out.mp4"}]},
},
}
),
]
)
client = KlingClient(session=session)
task_id = client.create_classic_task("/v1/videos/text2video", {"prompt": "x"})
outputs = client.poll_classic("/v1/videos/text2video", task_id, "videos")
assert task_id == "task-1"
assert outputs == [{"url": "https://example.com/out.mp4"}]
def test_turbo_create_and_poll_parse_result_paths(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
session = FakeSession(
[
FakeResponse({"code": 0, "data": {"id": "turbo-1"}}),
FakeResponse(
{
"code": 0,
"data": [
{
"id": "turbo-1",
"status": "succeeded",
"outputs": [{"url": "https://example.com/out.mp4"}],
}
],
}
),
]
)
client = KlingClient(session=session)
task_id = client.create_turbo("/text-to-video/kling-3.0-turbo", {"prompt": "x"})
outputs = client.poll_turbo(task_id)
assert task_id == "turbo-1"
assert outputs == [{"url": "https://example.com/out.mp4"}]
def test_schema_snapshot_contains_phase1_contract_fields():
fixture = PROJECT_ROOT / "tests/fixtures/kling_official/schema_snapshot.json"
data = json.loads(fixture.read_text())
assert data["build_id"] == "97344324"
assert "index-B9E4in0e.js" in data["chunk_names"]
assert "document-navigation-nxVgwiS5.js" in data["chunk_names"]
assert data["api_base"]["auth_env"] == "KLING_API_KEY"
assert data["task_statuses"]["classic"] == ["submitted", "processing", "succeed", "failed"]
assert data["task_statuses"]["turbo"] == ["submitted", "processing", "succeeded", "failed"]
assert data["result_paths"]["classic_created_id"] == "data.task_id"
assert data["result_paths"]["turbo_created_id"] == "data.id"
assert "kling-v3" in data["models"]["video"]
assert "kling-v3" in data["models"]["image"]
assert data["endpoints"]["tts"]["path"] == "/v1/audio/tts"
assert data["endpoints"]["avatar_image_to_video"]["path"] == "/v1/videos/avatar/image2video"
assert data["endpoints"]["identify_face"]["path"] == "/v1/videos/identify-face"
assert data["endpoints"]["advanced_lip_sync"]["path"] == "/v1/videos/advanced-lip-sync"
assert data["endpoints"]["video_effects"]["path"] == "/v1/videos/effects"
assert data["result_paths"]["classic_audio_results"] == "data.task_result.audios[]"
assert data["result_paths"]["identify_face_session"] == "data.session_id"
assert data["core_field_enums"]["tts_voice_language"] == ["zh", "en"]
assert data["core_field_enums"]["avatar_mode"] == ["std", "pro"]
def test_optional_live_doc_snapshot_check():
if os.environ.get("RUN_KLING_DOC_LIVE_CHECK") != "1":
pytest.skip("Set RUN_KLING_DOC_LIVE_CHECK=1 to compare fixture against current Kling docs HTML.")
import re
import urllib.request
fixture = PROJECT_ROOT / "tests/fixtures/kling_official/schema_snapshot.json"
expected = json.loads(fixture.read_text())
with urllib.request.urlopen("https://kling.ai/document-api/api/video/3-0-turbo/text-to-video", timeout=20) as response:
html = response.read().decode("utf-8", errors="ignore")
match = re.search(r'<meta name="buildId" content="([^"]+)"', html)
assert match, "Kling official docs HTML no longer exposes buildId; refresh schema fixture."
assert match.group(1) == expected["build_id"], "Kling official docs buildId changed; refresh schema fixture before implementation."

View File

@@ -0,0 +1,88 @@
"""Documentation and skill contract tests for Kling official integration."""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.graphics.kling_official_image import KlingOfficialImage
from tools.audio.kling_tts import KlingTTS
from tools.avatar.kling_avatar import KlingAvatar
from tools.avatar.kling_lip_sync import KlingLipSync
from tools.video.kling_official_video import KlingOfficialVideo
def read(path: str) -> str:
return (PROJECT_ROOT / path).read_text(encoding="utf-8")
def test_env_example_documents_kling_official_keys():
env = read(".env.example")
assert "KLING_API_KEY=" in env
assert "KLING_API_BASE_URL=" in env
def test_provider_docs_distinguish_fal_and_official_kling():
providers = read("docs/PROVIDERS.md")
assert "Kling Official" in providers
assert "kling_official_video" in providers
assert "kling_official_image" in providers
assert "kling_tts" in providers
assert "kling_avatar" in providers
assert "kling_lip_sync" in providers
assert "fal.ai" in providers
assert "provider=\"kling_official\"" in providers
assert "provider=\"kling\"" in providers
assert "Elements remain an internal Kling Official helper" in providers
assert "Account Usage is available as a low-frequency diagnostic helper" in providers
assert "callback_url" in providers
assert "audio effects and video effects are documented but intentionally not registered" in providers
def test_architecture_env_mapping_includes_kling_official():
architecture = read("docs/ARCHITECTURE.md")
assert "`KLING_API_KEY` | kling_official_video, kling_official_image, kling_tts, kling_avatar, kling_lip_sync" in architecture
assert "`KLING_API_BASE_URL` | kling_official_video, kling_official_image, kling_tts, kling_avatar, kling_lip_sync" in architecture
assert "Elements and Account" in architecture
assert "not separate pipeline stages" in architecture
assert "Kling Official Phase 3 adds provider tools only where OpenMontage already has a" in architecture
def test_ai_video_skill_metadata_and_new_skill_link():
ai_video = read(".agents/skills/ai-video-gen/SKILL.md")
index = read("skills/INDEX.md")
creative = read("skills/creative/video-gen-prompting.md")
official_skill = PROJECT_ROOT / ".agents/skills/kling-official/SKILL.md"
assert "KLING_API_KEY" in ai_video
assert "kling_official_video" in ai_video
assert "kling_tts" in index
assert "avatar/lip-sync face selection" in index
assert ".agents/skills/kling-official/" in creative
assert official_skill.is_file()
official_skill_text = official_skill.read_text(encoding="utf-8")
assert "Omni References" in official_skill_text
assert "Callback Notes" in official_skill_text
assert "TTS Parameters" in official_skill_text
assert "Lip Sync Parameters" in official_skill_text
assert "Audio Effects And Video Effects" in official_skill_text
def test_provider_agent_skills_reference_kling_official():
assert "kling-official" in KlingOfficialVideo().agent_skills
assert "kling-official" in KlingOfficialImage().agent_skills
assert "kling-official" in KlingTTS().agent_skills
assert "kling-official" in KlingAvatar().agent_skills
assert "kling-official" in KlingLipSync().agent_skills
def test_phase3_does_not_register_audio_or_video_effect_tools():
from tools.tool_registry import registry
registry.clear()
registry.discover("tools")
assert registry.get("kling_audio") is None
assert registry.get("kling_effects") is None

View File

@@ -0,0 +1,54 @@
"""Contract tests for the Kling official E2E smoke script."""
from __future__ import annotations
import importlib.util
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT_PATH = PROJECT_ROOT / "scripts" / "kling_official_animated_explainer_e2e.py"
def _load_script():
spec = importlib.util.spec_from_file_location("kling_official_animated_explainer_e2e", SCRIPT_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_env_status_redacts_secret_values():
script = _load_script()
status = script._env_status(
{
"KLING_API_KEY": "secret-token",
"KLING_API_BASE_URL": "https://api-beijing.klingai.com",
"FAL_KEY": "",
}
)
assert status["KLING_API_KEY"]["present"] is True
assert status["KLING_API_KEY"]["display"] == "<set:12 chars>"
assert "secret-token" not in repr(status)
assert status["KLING_API_BASE_URL"]["display"] == "https://api-beijing.klingai.com"
assert status["FAL_KEY"]["present"] is False
def test_cli_modes_are_explicit_and_non_paid_by_default():
script = _load_script()
assert script._execution_mode(script._parse_args([])) == "dry_run"
assert script._execution_mode(script._parse_args(["--live-tts"])) == "live_tts"
assert script._execution_mode(script._parse_args(["--live-full"])) == "live_full"
assert script._execution_mode(script._parse_args(["--live"])) == "live_full"
def test_video_duration_aligns_to_narration_within_kling_limits():
script = _load_script()
assert script._aligned_video_duration("3", 6.05) == "7"
assert script._aligned_video_duration("10", 6.05) == "10"
assert script._aligned_video_duration("3", None) == "3"
assert script._aligned_video_duration("3", 30.0) == "15"

View File

@@ -0,0 +1,166 @@
"""Contract tests for Kling official Phase 2 helpers."""
from __future__ import annotations
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools._kling.account import get_account_costs, reset_account_usage_cache
from tools._kling.client import KlingClient
from tools._kling.elements import (
get_custom_element,
list_custom_elements,
list_preset_elements,
normalize_element_list,
write_elements_artifact,
)
from tools.tool_registry import registry
class FakeClient:
def __init__(self, api_key="fake-key", base_url="https://api.example.test"):
self.api_key = api_key
self.base_url = base_url
self.calls = []
def get(self, path, params=None):
self.calls.append((path, params or {}))
if path.startswith("/v1/general/advanced-custom-elements/"):
return {"code": 0, "data": {"element_id": 123}}
if path == "/v1/general/advanced-custom-elements":
return {"code": 0, "data": [{"element_id": 456}]}
if path == "/v1/general/advanced-presets-elements":
return {"code": 0, "data": [{"element_id": 1}]}
return {"code": 0, "data": {"resource_pack_subscribe_infos": [{"name": "pack-a"}]}}
class FakeResponse:
status_code = 200
def json(self):
return {"code": 0, "data": {"resource_pack_subscribe_infos": [{"name": "pack-a"}]}}
class FakeSession:
def __init__(self):
self.calls = []
def get(self, url, **kwargs):
self.calls.append(("get", url, kwargs))
return FakeResponse()
def test_elements_helper_normalizes_and_records_metadata(tmp_path):
assert normalize_element_list([123, {"element_id": "456"}]) == [
{"element_id": 123},
{"element_id": 456},
]
artifact = write_elements_artifact(
tmp_path / "kling_elements.json",
[{"element_id": 123, "kind": "character", "name": "main-presenter"}],
)
data = json.loads(artifact.read_text())
assert data["provider"] == "kling_official"
assert data["elements"][0]["element_id"] == 123
try:
normalize_element_list([{"name": "missing-id"}])
except ValueError as exc:
assert "element_id" in str(exc)
else:
raise AssertionError("element_list items without element_id must be rejected")
def test_elements_helper_read_only_endpoints_do_not_enter_registry():
fake = FakeClient()
assert get_custom_element(123, client=fake)["data"]["element_id"] == 123
assert list_custom_elements(client=fake)["data"][0]["element_id"] == 456
assert list_preset_elements(client=fake)["data"][0]["element_id"] == 1
assert fake.calls == [
("/v1/general/advanced-custom-elements/123", {}),
("/v1/general/advanced-custom-elements", {}),
("/v1/general/advanced-presets-elements", {}),
]
import tools._kling.elements as elements_module
assert not hasattr(elements_module, "create_element")
assert not hasattr(elements_module, "delete_element")
registry.clear()
registry.discover("tools")
assert registry.get("kling_elements") is None
assert registry.get("kling_account_usage") is None
def test_account_usage_helper_uses_endpoint_cache_and_throttle():
reset_account_usage_cache()
fake = FakeClient()
first = get_account_costs(
start_time="2026-07-01",
end_time="2026-07-03",
client=fake,
now=100.0,
)
second = get_account_costs(
start_time="2026-07-01",
end_time="2026-07-03",
client=fake,
now=101.0,
)
throttled = get_account_costs(
resource_pack_name="different",
client=fake,
now=102.0,
)
assert fake.calls == [
("/account/costs", {"start_time": "2026-07-01", "end_time": "2026-07-03"})
]
assert first["throttle_status"] == "fresh"
assert second["cached"] is True
assert second["throttle_status"] == "cache_hit"
assert throttled["throttle_status"] == "throttled_no_cache"
def test_account_usage_cache_is_scoped_by_api_identity():
reset_account_usage_cache()
first_client = FakeClient(api_key="account-a")
second_client = FakeClient(api_key="account-b")
get_account_costs(client=first_client, now=100.0)
get_account_costs(client=second_client, now=111.0)
cached = get_account_costs(client=second_client, now=112.0)
assert first_client.calls == [("/account/costs", {})]
assert second_client.calls == [("/account/costs", {})]
assert cached["cached"] is True
def test_account_usage_helper_uses_kling_auth_header(monkeypatch):
reset_account_usage_cache()
monkeypatch.setenv("KLING_API_KEY", "test-key")
session = FakeSession()
client = KlingClient(session=session, max_retries=0)
result = get_account_costs(
start_time="2026-07-01",
end_time="2026-07-03",
resource_pack_name="starter",
client=client,
now=200.0,
)
assert result["resource_pack_subscribe_infos"][0]["name"] == "pack-a"
method, url, kwargs = session.calls[0]
assert method == "get"
assert url.endswith("/account/costs")
assert kwargs["headers"]["Authorization"] == "Bearer test-key"
assert kwargs["params"] == {
"start_time": "2026-07-01",
"end_time": "2026-07-03",
"resource_pack_name": "starter",
}

View File

@@ -0,0 +1,299 @@
"""Contract tests for the Kling official image provider."""
from __future__ import annotations
import base64
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.graphics.image_selector import ImageSelector
from tools.graphics.kling_official_image import KlingOfficialImage
from tools.tool_registry import registry
def test_registry_discovers_kling_official_image(monkeypatch):
monkeypatch.delenv("KLING_API_KEY", raising=False)
registry.clear()
registry.discover("tools")
tool = registry.get("kling_official_image")
assert tool is not None
assert tool.capability == "image_generation"
assert tool.provider == "kling_official"
def test_image_schema_and_skill():
tool = KlingOfficialImage()
props = tool.input_schema["properties"]
assert "image_url" in props
assert "api_family" in props
assert "kling-official" in tool.agent_skills
def test_generation_payload():
tool = KlingOfficialImage()
request = tool._build_request(
{
"prompt": "portrait of a launch engineer",
"negative_prompt": "blurry",
"api_family": "generation",
"resolution": "2k",
"n": 2,
"watermark": False,
}
)
assert request["path"] == "/v1/images/generations"
assert request["payload"]["model_name"] == "kling-v3"
assert request["payload"]["negative_prompt"] == "blurry"
assert request["payload"]["resolution"] == "2k"
assert request["payload"]["n"] == 2
assert request["payload"]["watermark_info"] == {"enabled": False}
def test_edit_payload_converts_image_path_to_base64(tmp_path):
image_path = tmp_path / "subject.png"
image_path.write_bytes(b"subject")
tool = KlingOfficialImage()
request = tool._build_request(
{
"prompt": "keep the subject, change background",
"generation_mode": "edit",
"image_path": str(image_path),
"image_reference": "subject",
}
)
assert request["path"] == "/v1/images/generations"
assert request["payload"]["image"] == base64.b64encode(b"subject").decode("ascii")
assert request["payload"]["image_reference"] == "subject"
def test_omni_payload_uses_image_list(tmp_path):
image_path = tmp_path / "ref.png"
image_path.write_bytes(b"ref")
tool = KlingOfficialImage()
request = tool._build_request(
{
"prompt": "combine <<<image_1>>> with neon product lighting",
"api_family": "omni",
"image_urls": ["https://example.com/ref-a.png"],
"image_paths": [str(image_path)],
"result_type": "series",
"series_amount": "3",
}
)
assert request["path"] == "/v1/images/omni-image"
assert request["payload"]["model_name"] == "kling-image-o1"
assert request["payload"]["image_list"][0] == {"image": "https://example.com/ref-a.png"}
assert request["payload"]["image_list"][1] == {"image": base64.b64encode(b"ref").decode("ascii")}
assert request["payload"]["series_amount"] == "3"
assert request["references_used"][0]["placeholder"] == "<<<image_1>>>"
def test_omni_prompt_helper_adds_placeholders_and_validates_counts():
tool = KlingOfficialImage()
request = tool._build_request(
{
"prompt": "combine these into one scene",
"api_family": "omni",
"image_urls": ["https://example.com/a.png", "https://example.com/b.png"],
}
)
assert "<<<image_1>>> <<<image_2>>>" in request["payload"]["prompt"]
assert request["references_used"][1]["source"] == "https://example.com/b.png"
existing = tool._build_request(
{
"prompt": "keep <<<image_1>>> as the subject",
"api_family": "omni",
"image_urls": ["https://example.com/a.png"],
}
)
assert existing["payload"]["prompt"].count("<<<image_1>>>") == 1
try:
tool._build_request(
{
"prompt": "use <<<image_2>>>",
"api_family": "omni",
"image_urls": ["https://example.com/a.png"],
}
)
except ValueError as exc:
assert "only 1 image" in str(exc)
else:
raise AssertionError("Image Omni placeholders must match provided image count")
def test_image_omni_element_list_and_callback_payload():
tool = KlingOfficialImage()
request = tool._build_request(
{
"prompt": "render with element",
"api_family": "omni",
"element_list": [{"element_id": "321"}],
"callback_url": "https://example.com/callback",
}
)
assert request["payload"]["element_list"] == [{"element_id": 321}]
assert request["payload"]["callback_url"] == "https://example.com/callback"
assert request["element_ids"] == [321]
try:
tool._build_request(
{
"prompt": "bad callback",
"api_family": "omni",
"callback_url": "ftp://example.com/callback",
}
)
except ValueError as exc:
assert "callback_url" in str(exc)
else:
raise AssertionError("callback_url must be an absolute http(s) URL")
def test_image_model_must_match_api_family():
tool = KlingOfficialImage()
try:
tool._build_request(
{
"prompt": "generation with omni model",
"api_family": "generation",
"model_name": "kling-image-o1",
}
)
except ValueError as exc:
assert "api_family=generation" in str(exc)
else:
raise AssertionError("generation requests must reject omni image models")
try:
tool._build_request(
{
"prompt": "omni with generation model",
"api_family": "omni",
"model_name": "kling-v3",
}
)
except ValueError as exc:
assert "api_family=omni" in str(exc)
else:
raise AssertionError("omni requests must reject generation image models")
def test_execute_downloads_all_image_results(monkeypatch, tmp_path):
class FakeClient:
def create_classic_task(self, path, payload):
return "img-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
return [
{"url": "https://example.com/a.png"},
{"url": "https://example.com/b.png"},
]
def download(self, url, output_path):
output_path.write_bytes(url.encode("utf-8"))
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.graphics.kling_official_image.KlingClient", lambda: FakeClient())
output_path = tmp_path / "image.png"
result = KlingOfficialImage().execute({"prompt": "x", "n": 2, "output_path": str(output_path)})
assert result.success
assert result.data["provider"] == "kling_official"
assert result.data["task_id"] == "img-task-1"
assert result.data["remote_outputs"][0]["url"].endswith("a.png")
assert len(result.artifacts) == 2
assert Path(result.artifacts[0]).read_bytes() == b"https://example.com/a.png"
assert Path(result.artifacts[1]).read_bytes() == b"https://example.com/b.png"
assert result.cost_usd > 0
def test_execute_image_omni_series_records_references_callback_and_artifacts(monkeypatch, tmp_path):
class FakeClient:
def create_classic_task(self, path, payload):
self.path = path
self.payload = payload
return "omni-img-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
return [
{"url": "https://example.com/series-a.png"},
{"url": "https://example.com/series-b.png"},
]
def download(self, url, output_path):
output_path.write_bytes(url.encode("utf-8"))
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.graphics.kling_official_image.KlingClient", lambda: FakeClient())
result = KlingOfficialImage().execute(
{
"prompt": "series from references",
"api_family": "omni",
"image_urls": ["https://example.com/ref.png"],
"element_list": [654],
"result_type": "series",
"series_amount": "2",
"callback_url": "https://example.com/callback",
"output_path": str(tmp_path / "series.png"),
}
)
assert result.success
assert result.data["api_family"] == "omni"
assert result.data["remote_outputs"][1]["url"].endswith("series-b.png")
assert result.data["references_used"][0]["placeholder"] == "<<<image_1>>>"
assert result.data["element_ids"] == [654]
assert result.data["callback_requested"] is True
assert result.data["polling_used"] is True
assert len(result.artifacts) == 2
assert Path(result.artifacts[1]).name == "series_2.png"
def test_image_selector_prefers_official_provider(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
registry.clear()
registry.register(KlingOfficialImage())
registry.register(ImageSelector())
registry._discovered_packages.add("tools")
def fake_execute(self, inputs):
from tools.base_tool import ToolResult
return ToolResult(success=True, data={"output_path": "out.png"}, artifacts=["out.png"])
monkeypatch.setattr(KlingOfficialImage, "execute", fake_execute)
result = registry.get("image_selector").execute(
{
"prompt": "official image",
"preferred_provider": "kling_official",
"api_family": "omni",
"image_reference": "subject",
}
)
assert result.success
assert result.data["selected_provider"] == "kling_official"
def test_image_cost_estimate_is_not_zero():
tool = KlingOfficialImage()
assert tool.estimate_cost({"prompt": "x"}) > 0
base = tool.estimate_cost({"prompt": "x", "api_family": "omni"})
series = tool.estimate_cost(
{
"prompt": "x",
"api_family": "omni",
"result_type": "series",
"series_amount": "3",
"resolution": "4k",
"image_urls": ["https://example.com/a.png", "https://example.com/b.png"],
}
)
assert series > base
dry_run = tool.dry_run({"prompt": "x"})
assert dry_run["cost_estimate_confidence"] == "low"

View File

@@ -0,0 +1,422 @@
"""Contract tests for the Kling official video provider."""
from __future__ import annotations
import base64
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools._kling.account import reset_account_usage_cache
from tools.tool_registry import registry
from tools._kling.errors import KlingAPIError
from tools.video.kling_official_video import KlingOfficialVideo
from tools.video.video_selector import VideoSelector
def test_registry_discovers_kling_official_video(monkeypatch):
monkeypatch.delenv("KLING_API_KEY", raising=False)
registry.clear()
registry.discover("tools")
tool = registry.get("kling_official_video")
assert tool is not None
assert tool.capability == "video_generation"
assert tool.provider == "kling_official"
def test_video_schema_has_no_top_level_image_url_and_has_skill():
tool = KlingOfficialVideo()
props = tool.input_schema["properties"]
assert "image_url" not in props
assert "reference_image_url" in props
assert "kling-official" in tool.agent_skills
assert "ai-video-gen" in tool.agent_skills
def test_classic_text_to_video_payload():
tool = KlingOfficialVideo()
request = tool._build_request(
{
"prompt": "cinematic robot walking through rain",
"api_family": "classic",
"operation": "text_to_video",
"duration": "5",
"aspect_ratio": "9:16",
"watermark": False,
}
)
assert request["path"] == "/v1/videos/text2video"
assert request["protocol"] == "classic"
assert request["payload"]["model_name"] == "kling-v3"
assert request["payload"]["prompt"].startswith("cinematic")
assert request["payload"]["aspect_ratio"] == "9:16"
assert request["payload"]["watermark_info"] == {"enabled": False}
def test_classic_image_to_video_uses_reference_image_path(tmp_path):
image_path = tmp_path / "ref.png"
image_path.write_bytes(b"fake-image")
tool = KlingOfficialVideo()
request = tool._build_request(
{
"prompt": "animate the frame",
"api_family": "classic",
"operation": "image_to_video",
"reference_image_path": str(image_path),
}
)
assert request["path"] == "/v1/videos/image2video"
assert request["payload"]["image"] == base64.b64encode(b"fake-image").decode("ascii")
assert "aspect_ratio" not in request["payload"]
def test_turbo_payloads():
tool = KlingOfficialVideo()
text_request = tool._build_request(
{
"prompt": "fast product reveal",
"api_family": "turbo",
"operation": "text_to_video",
"duration": "6",
"resolution": "1080p",
}
)
assert text_request["path"] == "/text-to-video/kling-3.0-turbo"
assert text_request["payload"]["settings"] == {
"resolution": "1080p",
"duration": 6,
"aspect_ratio": "16:9",
}
image_request = tool._build_request(
{
"prompt": "animate the product",
"api_family": "turbo",
"operation": "image_to_video",
"reference_image_url": "https://example.com/ref.png",
}
)
assert image_request["path"] == "/image-to-video/kling-3.0-turbo"
assert image_request["payload"]["contents"] == [
{"type": "prompt", "text": "animate the product"},
{"type": "first_frame", "url": "https://example.com/ref.png"},
]
def test_omni_reference_payload():
tool = KlingOfficialVideo()
request = tool._build_request(
{
"prompt": "match the motion and mood",
"api_family": "omni",
"operation": "reference_to_video",
"video_list": [{"video_url": "https://example.com/ref.mp4", "refer_type": "base"}],
}
)
assert request["path"] == "/v1/videos/omni-video"
assert request["payload"]["model_name"] == "kling-video-o1"
assert request["payload"]["video_list"][0]["video_url"].endswith("ref.mp4")
def test_video_omni_payload_supports_multi_refs_elements_and_multi_prompt(tmp_path):
image_path = tmp_path / "local.png"
image_path.write_bytes(b"local-ref")
tool = KlingOfficialVideo()
request = tool._build_request(
{
"prompt": "two-shot brand reveal",
"api_family": "omni",
"operation": "reference_to_video",
"image_list": [{"image_url": "https://example.com/start.png", "type": "first_frame"}],
"reference_tail_image_path": str(image_path),
"video_list": [
{
"video_url": "https://example.com/motion.mp4",
"refer_type": "feature",
"keep_original_sound": True,
}
],
"element_list": [123, {"element_id": "456"}],
"multi_shot": True,
"shot_type": "customize",
"multi_prompt": [
{"prompt": "wide product intro", "duration": "5"},
{"prompt": "close detail pass", "camera_control": {"type": "simple"}},
],
}
)
payload = request["payload"]
assert payload["image_list"][0] == {"image_url": "https://example.com/start.png", "type": "first_frame"}
assert payload["image_list"][1] == {
"image_url": base64.b64encode(b"local-ref").decode("ascii"),
"type": "end_frame",
}
assert payload["video_list"] == [
{
"video_url": "https://example.com/motion.mp4",
"refer_type": "feature",
"keep_original_sound": "yes",
}
]
assert payload["element_list"] == [{"element_id": 123}, {"element_id": 456}]
assert payload["multi_shot"] is True
assert payload["multi_prompt"][1]["camera_control"] == {"type": "simple"}
assert request["element_ids"] == [123, 456]
assert any(item["kind"] == "element" for item in request["references_used"])
def test_video_omni_requires_reference_input_and_rejects_local_video_paths():
tool = KlingOfficialVideo()
try:
tool._build_request(
{
"prompt": "needs a reference",
"api_family": "omni",
"operation": "reference_to_video",
}
)
except ValueError as exc:
assert "requires image_list, video_list, element_list" in str(exc)
else:
raise AssertionError("reference_to_video must require at least one Omni reference")
try:
tool._build_request(
{
"prompt": "local video",
"api_family": "omni",
"operation": "reference_to_video",
"reference_video_path": "/tmp/ref.mp4",
}
)
except ValueError as exc:
assert "local video paths cannot be silently uploaded" in str(exc)
else:
raise AssertionError("Video Omni must not silently upload local videos")
def test_video_callback_payloads_and_validation():
tool = KlingOfficialVideo()
classic = tool._build_request(
{
"prompt": "callback classic",
"api_family": "classic",
"operation": "text_to_video",
"callback_url": "https://example.com/kling/callback",
}
)
assert classic["payload"]["callback_url"] == "https://example.com/kling/callback"
turbo = tool._build_request(
{
"prompt": "callback turbo",
"api_family": "turbo",
"operation": "text_to_video",
"callback_url": "https://example.com/kling/callback",
}
)
assert turbo["payload"]["options"]["callback_url"] == "https://example.com/kling/callback"
omni = tool._build_request(
{
"prompt": "callback omni",
"api_family": "omni",
"operation": "reference_to_video",
"video_list": [{"video_url": "https://example.com/ref.mp4"}],
"callback_url": "https://example.com/kling/callback",
}
)
assert omni["payload"]["callback_url"] == "https://example.com/kling/callback"
try:
tool._build_request(
{
"prompt": "bad callback",
"api_family": "classic",
"operation": "text_to_video",
"callback_url": "not-a-url",
}
)
except ValueError as exc:
assert "callback_url" in str(exc)
else:
raise AssertionError("callback_url must be validated before sending")
def test_video_model_must_match_api_family():
tool = KlingOfficialVideo()
try:
tool._build_request(
{
"prompt": "classic request with omni model",
"api_family": "classic",
"operation": "text_to_video",
"model_name": "kling-video-o1",
}
)
except ValueError as exc:
assert "api_family=classic" in str(exc)
else:
raise AssertionError("classic requests must reject omni video models")
try:
tool._build_request(
{
"prompt": "omni request with classic model",
"api_family": "omni",
"operation": "reference_to_video",
"model_name": "kling-v3",
"video_list": [{"video_url": "https://example.com/ref.mp4"}],
}
)
except ValueError as exc:
assert "api_family=omni" in str(exc)
else:
raise AssertionError("omni requests must reject classic video models")
def test_execute_downloads_video_and_returns_artifact(monkeypatch, tmp_path):
class FakeClient:
def create_classic_task(self, path, payload):
self.path = path
self.payload = payload
return "task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
return [{"url": "https://example.com/out.mp4"}]
def download(self, url, output_path):
output_path.write_bytes(b"video")
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.video.kling_official_video.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.video.kling_official_video.probe_output", lambda path: {"duration_seconds": 5.0})
output_path = tmp_path / "out.mp4"
result = KlingOfficialVideo().execute({"prompt": "x", "output_path": str(output_path)})
assert result.success
assert result.data["provider"] == "kling_official"
assert result.data["task_id"] == "task-1"
assert result.artifacts == [str(output_path)]
assert output_path.read_bytes() == b"video"
assert result.cost_usd > 0
def test_execute_downloads_all_omni_video_outputs_and_records_metadata(monkeypatch, tmp_path):
reset_account_usage_cache()
class FakeClient:
def create_classic_task(self, path, payload):
self.path = path
self.payload = payload
return "omni-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
return [
{"url": "https://example.com/a.mp4"},
{"url": "https://example.com/b.mp4"},
]
def download(self, url, output_path):
output_path.write_bytes(url.encode("utf-8"))
return output_path
def get(self, path, params=None):
assert path == "/account/costs"
return {"code": 0, "data": {"resource_pack_subscribe_infos": [{"name": "pack-a"}]}}
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.video.kling_official_video.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.video.kling_official_video.probe_output", lambda path: {"duration_seconds": 5.0})
result = KlingOfficialVideo().execute(
{
"prompt": "omni",
"api_family": "omni",
"operation": "reference_to_video",
"video_list": [{"video_url": "https://example.com/ref.mp4", "refer_type": "base"}],
"element_list": [789],
"callback_url": "https://example.com/callback",
"include_account_usage": True,
"output_path": str(tmp_path / "out.mp4"),
}
)
assert result.success
assert result.data["api_family"] == "omni"
assert result.data["remote_outputs"][1]["url"].endswith("b.mp4")
assert result.data["element_ids"] == [789]
assert result.data["callback_requested"] is True
assert result.data["polling_used"] is True
assert result.data["account_usage"]["resource_pack_subscribe_infos"][0]["name"] == "pack-a"
assert result.data["cost_source"] == "estimate_with_account_usage_context"
assert result.cost_usd > 0
assert len(result.artifacts) == 2
assert Path(result.artifacts[1]).name == "out_2.mp4"
def test_video_selector_prefers_official_provider_without_fal_upload(monkeypatch, tmp_path):
monkeypatch.setenv("KLING_API_KEY", "test-key")
image_path = tmp_path / "ref.png"
image_path.write_bytes(b"fake")
registry.clear()
registry.register(KlingOfficialVideo())
registry.register(VideoSelector())
registry._discovered_packages.add("tools")
seen = {}
def fake_execute(self, inputs):
seen.update(inputs)
from tools.base_tool import ToolResult
return ToolResult(success=True, data={"output_path": "out.mp4"}, artifacts=["out.mp4"])
def fail_upload(path):
raise AssertionError("fal.ai upload should not be called for kling_official_video")
monkeypatch.setattr(KlingOfficialVideo, "execute", fake_execute)
monkeypatch.setattr("tools.video._shared.upload_image_fal", fail_upload)
result = registry.get("video_selector").execute(
{
"prompt": "animate",
"operation": "image_to_video",
"preferred_provider": "kling_official",
"reference_image_path": str(image_path),
}
)
assert result.success
assert result.data["selected_provider"] == "kling_official"
assert seen["reference_image_path"] == str(image_path)
def test_video_cost_estimate_is_not_zero():
tool = KlingOfficialVideo()
assert tool.estimate_cost({"prompt": "x"}) > 0
base = tool.estimate_cost({"prompt": "x", "api_family": "omni"})
expensive = tool.estimate_cost(
{
"prompt": "x",
"api_family": "omni",
"mode": "4k",
"sound": "on",
"video_list": [{"video_url": "https://example.com/ref.mp4"}],
"element_list": [1, 2],
"multi_prompt": [{"prompt": "a"}, {"prompt": "b"}],
}
)
assert expensive > base
dry_run = tool.dry_run({"prompt": "x"})
assert dry_run["cost_estimate_confidence"] == "low"
def test_video_account_resource_error_includes_diagnostic(monkeypatch):
class FakeClient:
def create_classic_task(self, path, payload):
raise KlingAPIError("resource pack exhausted", code=1102, request_id="req-1")
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.video.kling_official_video.KlingClient", lambda: FakeClient())
result = KlingOfficialVideo().execute({"prompt": "x"})
assert not result.success
assert result.data["account_usage_diagnostic"]["reason"] == "account_balance_or_resource_pack"

View File

@@ -0,0 +1,177 @@
"""Contract tests for the Kling official TTS provider."""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from tools.audio.kling_tts import KlingTTS
from tools.audio.tts_selector import TTSSelector
from tools.tool_registry import registry
def test_registry_discovers_kling_tts(monkeypatch):
monkeypatch.delenv("KLING_API_KEY", raising=False)
registry.clear()
registry.discover("tools")
tool = registry.get("kling_tts")
assert tool is not None
assert tool.capability == "tts"
assert tool.provider == "kling_official"
def test_tts_schema_and_skill_metadata():
tool = KlingTTS()
props = tool.input_schema["properties"]
assert "voice_id" in props
assert props["voice_language"]["enum"] == ["zh", "en"]
assert "kling-official" in tool.agent_skills
assert "text-to-speech" in tool.agent_skills
assert tool.estimate_cost({"text": "hello", "voice_id": "voice-a"}) > 0
assert tool.dry_run({"text": "hello", "voice_id": "voice-a"})["cost_estimate_confidence"] == "low"
def test_tts_payload_and_validation():
tool = KlingTTS()
request = tool._build_request(
{
"text": "Hello from Kling",
"voice_id": "voice-a",
"voice_language": "en",
"voice_speed": 1.2,
"callback_url": "https://example.com/kling/callback",
}
)
assert request["path"] == "/v1/audio/tts"
assert request["payload"] == {
"text": "Hello from Kling",
"voice_id": "voice-a",
"voice_language": "en",
"voice_speed": 1.2,
"callback_url": "https://example.com/kling/callback",
}
for bad_inputs in (
{"text": "missing voice"},
{"text": "x", "voice_id": "voice-a", "voice_language": "fr"},
{"text": "x", "voice_id": "voice-a", "voice_speed": 9},
):
try:
tool._build_request(bad_inputs)
except ValueError:
pass
else:
raise AssertionError(f"Invalid TTS inputs should fail: {bad_inputs}")
def test_execute_downloads_all_audio_results(monkeypatch, tmp_path):
class FakeClient:
def create_classic_task(self, path, payload):
self.path = path
self.payload = payload
return "tts-task-1"
def poll_classic(self, path, task_id, result_key, timeout_seconds, poll_interval):
assert result_key == "audios"
return [
{"url": "https://example.com/a.mp3"},
{"audio_url": "https://example.com/b.wav"},
]
def download(self, url, output_path):
output_path.write_bytes(url.encode("utf-8"))
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.audio.kling_tts.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.audio.kling_tts.probe_duration", lambda path: 1.23)
output_path = tmp_path / "speech.mp3"
result = KlingTTS().execute(
{
"text": "Hello",
"voice_id": "voice-a",
"output_path": str(output_path),
}
)
assert result.success
assert result.data["provider"] == "kling_official"
assert result.data["task_id"] == "tts-task-1"
assert result.data["audio_duration_seconds"] == 1.23
assert len(result.artifacts) == 2
assert Path(result.artifacts[0]).name == "speech.mp3"
assert Path(result.artifacts[1]).name == "speech_2.wav"
assert result.cost_usd > 0
def test_execute_accepts_synchronous_create_response(monkeypatch, tmp_path):
class FakeClient:
def post(self, path, payload):
assert path == "/v1/audio/tts"
assert payload["voice_id"] == "voice-a"
return {
"code": 0,
"message": "SUCCEED",
"request_id": "req-1",
"data": {
"task_id": "tts-task-sync",
"task_status": "succeed",
"task_result": {
"audios": [
{"url": "https://example.com/sync.mp3"},
]
},
},
}
def poll_classic(self, *args, **kwargs):
raise AssertionError("synchronous TTS response should not poll")
def download(self, url, output_path):
output_path.write_bytes(url.encode("utf-8"))
return output_path
monkeypatch.setenv("KLING_API_KEY", "test-key")
monkeypatch.setattr("tools.audio.kling_tts.KlingClient", lambda: FakeClient())
monkeypatch.setattr("tools.audio.kling_tts.probe_duration", lambda path: 2.5)
result = KlingTTS().execute(
{
"text": "Hello",
"voice_id": "voice-a",
"output_path": str(tmp_path / "sync.mp3"),
}
)
assert result.success
assert result.data["task_id"] == "tts-task-sync"
assert result.data["remote_outputs"] == [{"url": "https://example.com/sync.mp3"}]
assert result.data["audio_duration_seconds"] == 2.5
def test_tts_selector_prefers_kling_official(monkeypatch):
monkeypatch.setenv("KLING_API_KEY", "test-key")
registry.clear()
registry.register(KlingTTS())
registry.register(TTSSelector())
registry._discovered_packages.add("tools")
def fake_execute(self, inputs):
from tools.base_tool import ToolResult
return ToolResult(success=True, data={"output_path": "out.mp3"}, artifacts=["out.mp3"])
monkeypatch.setattr(KlingTTS, "execute", fake_execute)
result = registry.get("tts_selector").execute(
{
"text": "official speech",
"voice_id": "voice-a",
"preferred_provider": "kling_official",
}
)
assert result.success
assert result.data["selected_provider"] == "kling_official"

View File

@@ -143,7 +143,7 @@ class TestCapabilityMetadata:
catalog = reg.capability_catalog()
assert "tts" in catalog
providers = {item["provider"] for item in catalog["tts"] if item["provider"] != "selector"}
assert providers == {"doubao", "elevenlabs", "google_tts", "openai", "piper"}
assert providers == {"doubao", "elevenlabs", "google_tts", "kling_official", "openai", "piper"}
# ---- Animated Explainer Pipeline ----

View File

@@ -0,0 +1,226 @@
{
"build_id": "97344324",
"source_urls": [
"https://kling.ai/document-api/api/get-started/authentication",
"https://kling.ai/document-api/api/get-started/error-codes",
"https://kling.ai/document-api/api/get-started/concurrency-rules",
"https://kling.ai/document-api/api/video/3-0-turbo/text-to-video",
"https://kling.ai/document-api/api/video/3-0-turbo/image-to-video",
"https://kling.ai/document-api/api/video/3-0-omni/text-to-video",
"https://kling.ai/document-api/api/video/3-0-omni/image-to-video",
"https://kling.ai/document-api/api/video/3-0-omni/video-omni",
"https://kling.ai/document-api/api/image/3-0-omni/image-generation",
"https://kling.ai/document-api/api/image/3-0-omni/image-omni",
"https://kling.ai/document-api/api/video/audio-generation/text-to-audio",
"https://kling.ai/document-api/api/video/audio-generation/video-to-audio",
"https://kling.ai/document-api/api/video/avatar",
"https://kling.ai/document-api/api/video/lip-sync",
"https://kling.ai/document-api/api/video/effects"
],
"chunk_names": [
"index-B9E4in0e.js",
"document-navigation-nxVgwiS5.js"
],
"extracted_at": "2026-07-03T08:12:55Z",
"api_base": {
"default": "https://api-singapore.klingai.com",
"env_override": "KLING_API_BASE_URL",
"auth_env": "KLING_API_KEY",
"auth_header": "Authorization: Bearer <KLING_API_KEY>"
},
"endpoints": {
"classic_text_to_video": {
"method": "POST",
"path": "/v1/videos/text2video",
"poll": "GET /v1/videos/text2video/{id}"
},
"classic_image_to_video": {
"method": "POST",
"path": "/v1/videos/image2video",
"poll": "GET /v1/videos/image2video/{id}"
},
"turbo_text_to_video": {
"method": "POST",
"path": "/text-to-video/kling-3.0-turbo",
"poll": "GET /tasks?task_ids=<id>"
},
"turbo_image_to_video": {
"method": "POST",
"path": "/image-to-video/kling-3.0-turbo",
"poll": "GET /tasks?task_ids=<id>"
},
"video_omni": {
"method": "POST",
"path": "/v1/videos/omni-video",
"poll": "GET /v1/videos/omni-video/{id}"
},
"image_generation": {
"method": "POST",
"path": "/v1/images/generations",
"poll": "GET /v1/images/generations/{id}"
},
"image_omni": {
"method": "POST",
"path": "/v1/images/omni-image",
"poll": "GET /v1/images/omni-image/{id}"
},
"tts": {
"method": "POST",
"path": "/v1/audio/tts",
"poll": "GET /v1/audio/tts/{id}"
},
"text_to_audio": {
"method": "POST",
"path": "/v1/audio/text-to-audio",
"poll": "GET /v1/audio/text-to-audio/{id}"
},
"video_to_audio": {
"method": "POST",
"path": "/v1/audio/video-to-audio",
"poll": "GET /v1/audio/video-to-audio/{id}"
},
"avatar_image_to_video": {
"method": "POST",
"path": "/v1/videos/avatar/image2video",
"poll": "GET /v1/videos/avatar/image2video/{id}"
},
"identify_face": {
"method": "POST",
"path": "/v1/videos/identify-face"
},
"advanced_lip_sync": {
"method": "POST",
"path": "/v1/videos/advanced-lip-sync",
"poll": "GET /v1/videos/advanced-lip-sync/{id}"
},
"video_effects": {
"method": "POST",
"path": "/v1/videos/effects",
"poll": "GET /v1/videos/effects/{id}"
}
},
"models": {
"video": [
"kling-v1",
"kling-v1-5",
"kling-v1-6",
"kling-v2-master",
"kling-v2-1",
"kling-v2-1-master",
"kling-v2-5-turbo",
"kling-v2-6",
"kling-v3",
"kling-video-o1",
"kling-v3-omni"
],
"image": [
"kling-v1",
"kling-v1-5",
"kling-v2",
"kling-v2-new",
"kling-v2-1",
"kling-v3",
"kling-image-o1",
"kling-v3-omni"
]
},
"task_statuses": {
"classic": [
"submitted",
"processing",
"succeed",
"failed"
],
"turbo": [
"submitted",
"processing",
"succeeded",
"failed"
]
},
"result_paths": {
"classic_created_id": "data.task_id",
"turbo_created_id": "data.id",
"classic_video_results": "data.task_result.videos[]",
"classic_image_results": "data.task_result.images[]",
"classic_audio_results": "data.task_result.audios[]",
"identify_face_session": "data.session_id",
"turbo_results": "data[0].outputs[]"
},
"core_field_enums": {
"aspect_ratio": [
"16:9",
"9:16",
"1:1"
],
"video_duration": [
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15"
],
"video_resolution": [
"720p",
"1080p"
],
"image_resolution": [
"1k",
"2k",
"4k"
],
"mode": [
"std",
"pro",
"4k"
],
"sound": [
"on",
"off"
],
"image_aspect_ratio": [
"16:9",
"9:16",
"1:1",
"4:3",
"3:4",
"3:2",
"2:3",
"21:9",
"auto"
],
"image_reference": [
"subject",
"face"
],
"image_result_type": [
"single",
"series"
],
"tts_voice_language": [
"zh",
"en"
],
"avatar_mode": [
"std",
"pro"
],
"lip_sync_operation": [
"identify_face",
"advanced_lip_sync",
"full_lip_sync"
]
},
"notes": [
"Official docs were fetched as a SPA on 2026-07-03. Current HTML exposes buildId 97344324.",
"The current entry/navigation bundles no longer expose the older api-*.js chunk names as literal references, so this fixture records the current entry assets and the Phase 1 core schema facts used by implementation and tests."
]
}