mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-25 17:42:50 +08:00
Add official Kling API providers
This commit is contained in:
120
tests/contracts/test_kling_avatar.py
Normal file
120
tests/contracts/test_kling_avatar.py
Normal 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"
|
||||
233
tests/contracts/test_kling_lip_sync.py
Normal file
233
tests/contracts/test_kling_lip_sync.py
Normal 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"
|
||||
179
tests/contracts/test_kling_official_client.py
Normal file
179
tests/contracts/test_kling_official_client.py
Normal 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."
|
||||
88
tests/contracts/test_kling_official_docs.py
Normal file
88
tests/contracts/test_kling_official_docs.py
Normal 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
|
||||
54
tests/contracts/test_kling_official_e2e_script.py
Normal file
54
tests/contracts/test_kling_official_e2e_script.py
Normal 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"
|
||||
166
tests/contracts/test_kling_official_helpers.py
Normal file
166
tests/contracts/test_kling_official_helpers.py
Normal 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",
|
||||
}
|
||||
299
tests/contracts/test_kling_official_image.py
Normal file
299
tests/contracts/test_kling_official_image.py
Normal 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"
|
||||
422
tests/contracts/test_kling_official_video.py
Normal file
422
tests/contracts/test_kling_official_video.py
Normal 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"
|
||||
177
tests/contracts/test_kling_tts.py
Normal file
177
tests/contracts/test_kling_tts.py
Normal 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"
|
||||
@@ -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 ----
|
||||
|
||||
226
tests/fixtures/kling_official/schema_snapshot.json
vendored
Normal file
226
tests/fixtures/kling_official/schema_snapshot.json
vendored
Normal 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."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user