Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ SUNO_API_KEY= # Suno AI music generation (full songs, instrumenta
# --- Video Generation ---
HEYGEN_API_KEY= # HeyGen API (VEO, Sora, Runway, Kling, Seedance via single key)
RUNWAY_API_KEY= # Runway Gen-4 (direct API, alternative to fal.ai routing)
VOLC_ACCESSKEY= # Volcengine Jimeng (即梦 AI) video generation via official API (HMAC-SHA256 V4 signing)
VOLC_SECRETKEY= # Secret Access Key paired with VOLC_ACCESSKEY. Get both at https://console.volcengine.com/iam/keymanage
VIDEO_GEN_LOCAL_ENABLED= # Set to "true" for local video gen (needs GPU + diffusers)
VIDEO_GEN_LOCAL_MODEL= # Local model: wan2.1-1.3b, wan2.1-14b, hunyuan-1.5, ltx2-local, cogvideo-5b
MODAL_LTX2_ENDPOINT_URL= # Modal self-hosted LTX-2 endpoint (optional)
Expand Down
38 changes: 38 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,44 @@ OpenMontage now uses those published rates in the Grok tool estimators.

---

### Volcengine Jimeng — 即梦 AI Video Generation

> **Direct ByteDance API via V4 signing.** Calls the Volcengine visual API (visual.volcengineapi.com) with HMAC-SHA256 request signing using IAM AK/SK credentials. Supports text-to-video and image-to-video via Jimeng 3.0 Pro.

**Tools unlocked:** `jimeng_video`
**Env vars:** `VOLC_ACCESSKEY` (Access Key ID) + `VOLC_SECRETKEY` (Secret Access Key)

#### Setup

1. Go to [console.volcengine.com/iam/keymanage](https://console.volcengine.com/iam/keymanage)
2. Create a Volcengine account if you don't have one
3. Create an Access Key pair (AK + SK)
4. Ensure your account has access to Jimeng AI (即梦) video generation service
5. Add to `.env`: `VOLC_ACCESSKEY=...` and `VOLC_SECRETKEY=...`

#### What it's best for

- Direct ByteDance/Volcengine API quota usage
- Jimeng 3.0 Pro text-to-video and image-to-video
- Chinese-language prompt understanding
- Configurable frame count (121=5s, 241=10s) and aspect ratio

#### API notes

Authentication uses Volcengine IAM V4 signing (HMAC-SHA256), not a Bearer token. The signing process builds a canonical request, derives a signing key from SK → date → region → service, and signs the request.

API flow: `POST ?Action=CVSync2AsyncSubmitTask` → poll `POST ?Action=CVSync2AsyncGetResult` → download `video_url`.

The `req_key` for video is `jimeng_ti2v_v30_pro`. Success code is `10000`. Task statuses: `in_queue`, `generating`, `done`, `not_found`, `expired`.

#### Pricing

| Model | Price |
|------|-------|
| Jimeng 3.0 Pro (video) | ~$0.05/sec (check Volcengine console for actual rate) |

---

### Alibaba DashScope — Qwen Image + TTS + ASR

> **Best for Chinese-language production.** One key unlocks Qwen-Image generation, Qwen-TTS Mandarin narration, and Qwen-ASR with word-level timestamps — the only DashScope path that provides word-level granularity for subtitle alignment.
Expand Down
320 changes: 320 additions & 0 deletions tests/contracts/test_jimeng_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
"""Contract tests for the Volcengine Jimeng video provider tool.

These tests verify that the tool satisfies the BaseTool contract without
requiring real Volcengine AK/SK credentials or making any API calls.

Run: pytest tests/contracts/test_jimeng_video.py -v
"""

import pytest

from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ToolRuntime,
ToolStability,
ToolStatus,
ToolTier,
)
from tools.video.jimeng_video import JimengVideo


# ------------------------------------------------------------------
# Contract compliance
# ------------------------------------------------------------------

class TestContract:

def test_inherits_base_tool(self):
assert issubclass(JimengVideo, BaseTool)

def test_has_required_identity(self):
tool = JimengVideo()
assert tool.name == "jimeng_video"
assert tool.version
assert tool.provider == "volcengine"
assert tool.capability == "video_generation"
assert tool.tier == ToolTier.GENERATE
assert tool.stability == ToolStability.EXPERIMENTAL
assert tool.runtime == ToolRuntime.API

def test_execution_mode_is_async(self):
assert JimengVideo().execution_mode == ExecutionMode.ASYNC

def test_has_input_schema(self):
schema = JimengVideo().input_schema
assert schema.get("type") == "object"
props = schema.get("properties", {})
required = schema.get("required", [])
assert required == ["prompt"]
for field in required:
assert field in props

def test_has_capabilities(self):
tool = JimengVideo()
assert "text_to_video" in tool.capabilities
assert "image_to_video" in tool.capabilities

def test_has_agent_skills(self):
assert "ai-video-gen" in JimengVideo().agent_skills

def test_has_fallbacks(self):
tool = JimengVideo()
assert "minimax_video" in tool.fallback_tools
assert "kling_video" in tool.fallback_tools

def test_has_install_instructions(self):
tool = JimengVideo()
assert "VOLC_ACCESSKEY" in tool.install_instructions
assert "VOLC_SECRETKEY" in tool.install_instructions

def test_get_info_returns_dict(self):
info = JimengVideo().get_info()
assert isinstance(info, dict)
assert info["name"] == "jimeng_video"
assert info["provider"] == "volcengine"
assert info["runtime"] == "api"

def test_status_unavailable_without_keys(self, monkeypatch):
monkeypatch.delenv("VOLC_ACCESSKEY", raising=False)
monkeypatch.delenv("VOLC_SECRETKEY", raising=False)
assert JimengVideo().get_status() == ToolStatus.UNAVAILABLE

def test_status_available_with_keys(self, monkeypatch):
monkeypatch.setenv("VOLC_ACCESSKEY", "fake-ak")
monkeypatch.setenv("VOLC_SECRETKEY", "fake-sk")
assert JimengVideo().get_status() == ToolStatus.AVAILABLE

def test_status_unavailable_with_only_ak(self, monkeypatch):
monkeypatch.setenv("VOLC_ACCESSKEY", "fake-ak")
monkeypatch.delenv("VOLC_SECRETKEY", raising=False)
assert JimengVideo().get_status() == ToolStatus.UNAVAILABLE

def test_has_resource_profile(self):
rp = JimengVideo().resource_profile
assert rp.network_required is True
assert rp.vram_mb == 0

def test_has_retry_policy(self):
assert JimengVideo().retry_policy.max_retries >= 0

def test_has_side_effects(self):
side = JimengVideo().side_effects
assert len(side) > 0
assert any("API" in s for s in side)

def test_has_user_visible_verification(self):
assert len(JimengVideo().user_visible_verification) > 0

def test_lazy_imports_requests(self):
import importlib
import sys
mod_name = "tools.video.jimeng_video"
if "requests" in sys.modules:
del sys.modules["requests"]
importlib.reload(sys.modules[mod_name])

def test_estimate_cost_returns_float(self):
cost = JimengVideo().estimate_cost({"prompt": "x", "frames": 121})
assert isinstance(cost, float)
assert cost > 0.0

def test_dry_run_returns_dict(self):
result = JimengVideo().dry_run({"prompt": "test"})
assert isinstance(result, dict)
assert result["tool"] == "jimeng_video"


# ------------------------------------------------------------------
# Idempotency keys
# ------------------------------------------------------------------

class TestIdempotencyKeys:

def test_includes_all_output_affecting_fields(self):
fields = JimengVideo().idempotency_key_fields
for field in ("prompt", "operation", "image_url", "frames", "aspect_ratio", "seed"):
assert field in fields, f"missing idempotency field: {field}"

def test_excludes_execution_only_fields(self):
fields = JimengVideo().idempotency_key_fields
for field in ("output_path", "poll_interval_seconds", "timeout_seconds"):
assert field not in fields

def test_differs_on_frames(self):
tool = JimengVideo()
base = {"prompt": "x"}
assert tool.idempotency_key(base) != tool.idempotency_key({**base, "frames": 241})

def test_differs_on_aspect_ratio(self):
tool = JimengVideo()
base = {"prompt": "x"}
assert tool.idempotency_key({**base, "aspect_ratio": "16:9"}) != tool.idempotency_key(
{**base, "aspect_ratio": "9:16"}
)

def test_differs_on_seed(self):
tool = JimengVideo()
base = {"prompt": "x"}
assert tool.idempotency_key({**base, "seed": -1}) != tool.idempotency_key(
{**base, "seed": 42}
)

def test_differs_on_image_url(self):
tool = JimengVideo()
base = {"prompt": "x", "operation": "image_to_video"}
assert tool.idempotency_key(base) != tool.idempotency_key(
{**base, "image_url": "https://example.com/img.png"}
)


# ------------------------------------------------------------------
# Tool-specific behavior
# ------------------------------------------------------------------

class TestToolSpecific:

def test_default_frames_is_121(self):
tool = JimengVideo()
assert tool.input_schema["properties"]["frames"]["default"] == 121

def test_default_aspect_ratio_is_16_9(self):
tool = JimengVideo()
assert tool.input_schema["properties"]["aspect_ratio"]["default"] == "16:9"

def test_default_seed_is_negative_one(self):
tool = JimengVideo()
assert tool.input_schema["properties"]["seed"]["default"] == -1

def test_cost_scales_with_frames(self):
tool = JimengVideo()
cost_5s = tool.estimate_cost({"prompt": "x", "frames": 121})
cost_10s = tool.estimate_cost({"prompt": "x", "frames": 241})
assert cost_10s > cost_5s

def test_build_payload_t2v(self):
tool = JimengVideo()
payload = tool._build_payload({"prompt": "a cat"})
assert payload["req_key"] == "jimeng_ti2v_v30_pro"
assert payload["prompt"] == "a cat"
assert payload["frames"] == 121
assert payload["aspect_ratio"] == "16:9"
assert payload["seed"] == -1
assert "image_urls" not in payload

def test_build_payload_i2v_includes_image(self):
tool = JimengVideo()
payload = tool._build_payload({
"prompt": "motion",
"operation": "image_to_video",
"image_url": "https://example.com/img.png",
})
assert payload["image_urls"] == ["https://example.com/img.png"]

def test_build_payload_t2v_omits_image(self):
tool = JimengVideo()
payload = tool._build_payload({"prompt": "a cat", "operation": "text_to_video"})
assert "image_urls" not in payload

def test_i2v_without_image_fails(self, monkeypatch):
monkeypatch.setenv("VOLC_ACCESSKEY", "fake-ak")
monkeypatch.setenv("VOLC_SECRETKEY", "fake-sk")
result = JimengVideo().execute({"prompt": "test", "operation": "image_to_video"})
assert result.success is False
assert "image_url" in result.error

def test_no_keys_returns_error(self, monkeypatch):
monkeypatch.delenv("VOLC_ACCESSKEY", raising=False)
monkeypatch.delenv("VOLC_SECRETKEY", raising=False)
result = JimengVideo().execute({"prompt": "test"})
assert result.success is False
assert "VOLC_ACCESSKEY" in result.error
assert "VOLC_SECRETKEY" in result.error

def test_safe_error_redacts_keys(self, monkeypatch):
monkeypatch.setenv("VOLC_ACCESSKEY", "my-ak-secret")
monkeypatch.setenv("VOLC_SECRETKEY", "my-sk-secret")
redacted = JimengVideo._safe_error(
Exception("failed with ak=my-ak-secret sk=my-sk-secret")
)
assert "my-ak-secret" not in redacted
assert "my-sk-secret" not in redacted
assert "[redacted]" in redacted

def test_safe_error_no_empty_string_bug(self, monkeypatch):
"""Regression: when no keys are set, _safe_error must not mangle."""
monkeypatch.delenv("VOLC_ACCESSKEY", raising=False)
monkeypatch.delenv("VOLC_SECRETKEY", raising=False)
msg = JimengVideo._safe_error(Exception("abc"))
assert msg == "abc"

def test_sign_returns_authorization_header(self):
headers = JimengVideo._sign(
"POST", "/",
{"Action": "CVSync2AsyncSubmitTask", "Version": "2022-08-31"},
{}, b'{"prompt":"test"}',
"fake-ak", "fake-sk",
)
assert "Authorization" in headers
assert "HMAC-SHA256" in headers["Authorization"]
assert "fake-ak" in headers["Authorization"]
assert "Host" in headers
assert "X-Date" in headers
assert "X-Content-Sha256" in headers

def test_sign_includes_content_type(self):
headers = JimengVideo._sign("POST", "/", {}, {}, b"{}", "ak", "sk")
assert headers["Content-Type"] == "application/json"

def test_json_or_raise_returns_dict(self):
class FakeResp:
status_code = 200
def json(self):
return {"code": 10000, "data": {"task_id": "123"}}
assert JimengVideo._json_or_raise(FakeResp()) == {"code": 10000, "data": {"task_id": "123"}}

def test_json_or_raise_raises_on_non_json(self):
class FakeResp:
status_code = 500
def json(self):
raise ValueError("not JSON")
with pytest.raises(RuntimeError, match="Non-JSON"):
JimengVideo._json_or_raise(FakeResp())

def test_check_code_passes_on_success(self):
JimengVideo._check_code(200, {"code": 10000, "message": "Success"})

def test_check_code_raises_on_api_error(self):
with pytest.raises(RuntimeError, match="code=10008"):
JimengVideo._check_code(200, {"code": 10008, "message": "Insufficient balance"})

def test_check_code_raises_on_http_error(self):
with pytest.raises(RuntimeError, match="HTTP 401"):
JimengVideo._check_code(401, {"code": 10004, "message": "Auth failed"})

def test_check_code_defaults_to_success_when_code_missing(self):
"""If code field is absent on HTTP 2xx, default to 10000 (success)."""
JimengVideo._check_code(200, {"data": {"task_id": "123"}})


# ------------------------------------------------------------------
# Registry discovery
# ------------------------------------------------------------------

class TestRegistryDiscovery:

def test_discoverable(self):
from tools.tool_registry import ToolRegistry
registry = ToolRegistry()
registry.discover()
names = {t.name for t in registry._tools.values()}
assert "jimeng_video" in names

def test_distinct_from_other_minimax_tools(self):
from tools.tool_registry import ToolRegistry
registry = ToolRegistry()
registry.discover()
jimeng = [t for t in registry._tools.values() if t.name == "jimeng_video"]
assert len(jimeng) == 1
assert jimeng[0].provider == "volcengine"
Loading
Loading