feat: 视频计费增强与影子计费系统

This commit is contained in:
fawney19
2026-02-03 18:48:39 +08:00
parent ed68aebfb0
commit 00442c41fa
84 changed files with 6507 additions and 1678 deletions

View File

@@ -46,7 +46,7 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
def test_global_switch_disabled_blocks_conversion() -> None:
"""全局开关关闭时(环境变量 FORMAT_CONVERSION_ENABLED=false阻止转换"""
"""全局开关关闭时阻止转换"""
ok, needs_conv, reason = is_format_compatible(
"claude:chat",
"openai:chat",
@@ -57,7 +57,7 @@ def test_global_switch_disabled_blocks_conversion() -> None:
)
assert ok is False
assert needs_conv is False
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
assert reason and "格式转换已禁用" in reason
def test_endpoint_config_none_blocks_conversion() -> None:
@@ -223,7 +223,7 @@ def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
registry=MagicMock(),
)
assert ok is False
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
assert reason and "格式转换已禁用" in reason
def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None:
@@ -313,7 +313,7 @@ def test_openai_cli_to_openai_fails_without_converter() -> None:
def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
"""同族转换OPENAI/OPENAI_CLI也受全局开关限制(环境变量 FORMAT_CONVERSION_ENABLED=false"""
"""同族转换OPENAI/OPENAI_CLI也受全局开关限制"""
registry = MagicMock()
registry.can_convert_full.return_value = True
@@ -327,7 +327,7 @@ def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
)
assert ok is False
assert needs_conv is False
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
assert reason and "格式转换已禁用" in reason
def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:

View File

@@ -0,0 +1,301 @@
from unittest.mock import MagicMock
from src.models.database import GlobalModel, Model
from src.services.billing.default_rules import DefaultBillingRuleGenerator
from src.services.billing.formula_engine import FormulaEngine
from src.services.billing.rule_service import BillingRuleService
class TestDefaultBillingRuleGenerator:
def test_default_rule_basic_chat_cost(self) -> None:
global_model = GlobalModel(
name="test-model",
display_name="Test Model",
is_active=True,
default_price_per_request=0.01,
default_tiered_pricing={
"tiers": [
{
"up_to": None,
"input_price_per_1m": 3.0,
"output_price_per_1m": 15.0,
# cache prices intentionally omitted (legacy derives from input price)
}
]
},
)
rule = DefaultBillingRuleGenerator.generate_for_model(
global_model=global_model,
model=None,
task_type="chat",
)
engine = FormulaEngine()
result = engine.evaluate(
expression=rule.expression,
variables=rule.variables,
dimensions={
"input_tokens": 1000,
"output_tokens": 500,
"cache_creation_tokens": 200,
"cache_read_tokens": 300,
"request_count": 1,
# tier key
"total_input_context": 1000 + 300,
},
dimension_mappings=rule.dimension_mappings,
strict_mode=True,
)
assert result.status == "complete"
assert abs(float(result.cost) - 0.02134) < 1e-9
def test_default_rule_tiered_pricing_uses_total_input_context(self) -> None:
global_model = GlobalModel(
name="tiered-model",
display_name="Tiered Model",
is_active=True,
default_price_per_request=None,
default_tiered_pricing={
"tiers": [
{"up_to": 200000, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0},
{"up_to": None, "input_price_per_1m": 1.5, "output_price_per_1m": 7.5},
]
},
)
rule = DefaultBillingRuleGenerator.generate_for_model(
global_model=global_model,
model=None,
task_type="chat",
)
engine = FormulaEngine()
result = engine.evaluate(
expression=rule.expression,
variables=rule.variables,
dimensions={
"input_tokens": 250000,
"output_tokens": 10000,
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"request_count": 1,
"total_input_context": 250000,
},
dimension_mappings=rule.dimension_mappings,
strict_mode=True,
)
# 250000 * 1.5 / 1M = 0.375
# 10000 * 7.5 / 1M = 0.075
assert result.status == "complete"
assert abs(float(result.cost) - 0.45) < 1e-9
def test_default_rule_cache_ttl_pricing_overrides_cache_read_price(self) -> None:
global_model = GlobalModel(
name="ttl-model",
display_name="TTL Model",
is_active=True,
default_price_per_request=0.0,
default_tiered_pricing={
"tiers": [
{
"up_to": None,
"input_price_per_1m": 3.0,
"output_price_per_1m": 15.0,
"cache_read_price_per_1m": 0.3,
"cache_ttl_pricing": [
{"ttl_minutes": 5, "cache_read_price_per_1m": 0.3},
{"ttl_minutes": 60, "cache_read_price_per_1m": 0.5},
],
}
]
},
)
rule = DefaultBillingRuleGenerator.generate_for_model(
global_model=global_model,
model=None,
task_type="chat",
)
engine = FormulaEngine()
result = engine.evaluate(
expression=rule.expression,
variables=rule.variables,
dimensions={
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_tokens": 0,
"cache_read_tokens": 1000,
"cache_ttl_minutes": 60,
"request_count": 1,
"total_input_context": 0 + 1000,
},
dimension_mappings=rule.dimension_mappings,
strict_mode=True,
)
# TTL=60 should use cache_read_price_per_1m=0.5
# 1000 * 0.5 / 1M = 0.0005
assert result.status == "complete"
assert abs(float(result.cost) - 0.0005) < 1e-9
class TestBillingRuleServiceDefaultFallback:
def test_find_rule_returns_default_for_chat_when_no_db_rule(self) -> None:
from src.services.billing.cache import BillingCache
BillingCache.invalidate_all()
global_model = GlobalModel(
id="gm-1",
name="test-model",
display_name="Test Model",
is_active=True,
default_price_per_request=0.0,
default_tiered_pricing={
"tiers": [{"up_to": None, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0}]
},
)
model_obj = Model(
id="m-1",
provider_id="p-1",
global_model_id="gm-1",
provider_model_name="provider-test-model",
is_active=True,
tiered_pricing=None,
price_per_request=None,
)
model_obj.global_model = global_model
# Build a mock Session with deterministic query().filter().first() chain.
q_global = MagicMock()
q_global.filter.return_value.first.return_value = global_model
q_model = MagicMock()
q_model.filter.return_value.first.return_value = model_obj
q_rule_model = MagicMock()
q_rule_model.filter.return_value.first.return_value = None
q_rule_global = MagicMock()
q_rule_global.filter.return_value.first.return_value = None
db = MagicMock()
db.query.side_effect = [q_global, q_model, q_rule_model, q_rule_global]
lookup = BillingRuleService.find_rule(
db,
provider_id="p-1",
model_name="test-model",
task_type="chat",
)
assert lookup is not None
assert lookup.scope == "default"
assert lookup.rule.id == "__default__"
assert lookup.effective_task_type == "chat"
# Cached: second call should not touch db.query again.
db.query.reset_mock()
lookup2 = BillingRuleService.find_rule(
db,
provider_id="p-1",
model_name="test-model",
task_type="chat",
)
assert lookup2 is not None
assert lookup2.scope == "default"
assert db.query.call_count == 0
def test_find_rule_returns_default_for_video_when_require_rule_false(self) -> None:
from src.config.settings import config
from src.services.billing.cache import BillingCache
BillingCache.invalidate_all()
old_require = config.billing_require_rule
config.billing_require_rule = False
try:
global_model = GlobalModel(
id="gm-2",
name="video-model",
display_name="Video Model",
is_active=True,
default_price_per_request=0.0,
default_tiered_pricing={
"tiers": [
{"up_to": None, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
]
},
)
q_global = MagicMock()
q_global.filter.return_value.first.return_value = global_model
q_rule_global = MagicMock()
q_rule_global.filter.return_value.first.return_value = None
db = MagicMock()
# provider_id omitted -> only GlobalModel query + global BillingRule query
db.query.side_effect = [q_global, q_rule_global]
lookup = BillingRuleService.find_rule(
db,
provider_id=None,
model_name="video-model",
task_type="video",
)
assert lookup is not None
assert lookup.scope == "default"
assert lookup.rule.id == "__default__"
assert lookup.effective_task_type == "video"
finally:
config.billing_require_rule = old_require
def test_find_rule_returns_template_for_video_when_require_rule_true(self) -> None:
from src.config.settings import config
from src.services.billing.cache import BillingCache
BillingCache.invalidate_all()
old_require = config.billing_require_rule
config.billing_require_rule = True
try:
global_model = GlobalModel(
id="gm-3",
name="video-model-2",
display_name="Video Model 2",
is_active=True,
default_price_per_request=0.0,
default_tiered_pricing={
"tiers": [
{"up_to": None, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
]
},
)
q_global = MagicMock()
q_global.filter.return_value.first.return_value = global_model
q_rule_global = MagicMock()
q_rule_global.filter.return_value.first.return_value = None
db = MagicMock()
db.query.side_effect = [q_global, q_rule_global]
lookup = BillingRuleService.find_rule(
db,
provider_id=None,
model_name="video-model-2",
task_type="video",
)
assert lookup is not None
assert lookup.scope == "default"
assert lookup.rule.id == "__default__"
# Universal Billing Rule is now used for all task types
assert lookup.rule.name == "Universal Billing Rule"
finally:
config.billing_require_rule = old_require

View File

@@ -34,7 +34,7 @@ class TestDimensionCollectorRuntime:
),
]
dims = runtime.collect(
collectors=collectors,
collectors=collectors, # type: ignore[arg-type]
inp=DimensionCollectInput(
response={"usageMetadata": {"promptTokenCount": 123}},
),
@@ -57,7 +57,7 @@ class TestDimensionCollectorRuntime:
)
]
dims = runtime.collect(
collectors=collectors,
collectors=collectors, # type: ignore[arg-type]
inp=DimensionCollectInput(metadata={"result": {"file_size_bytes": 1048576}}),
)
assert abs(dims["file_size_mb"] - 1.0) < 1e-9
@@ -98,7 +98,7 @@ class TestDimensionCollectorRuntime:
),
]
dims = runtime.collect(
collectors=collectors,
collectors=collectors, # type: ignore[arg-type]
inp=DimensionCollectInput(
request={"usage": {"input_tokens": 100, "cache_read_tokens": 20}}
),
@@ -110,52 +110,13 @@ class TestDimensionCollectorRuntime:
class TestDimensionCollectorService:
def test_video_fallback_merges_base_collectors(self) -> None:
from src.services.billing.cache import BillingCache
BillingCache.invalidate_all()
# code-only: openai:video should fall back to openai:chat video collectors shipped in code
db = MagicMock()
video_collectors = [
DimensionCollector(
api_format="openai:video",
task_type="video",
dimension_name="duration_seconds",
source_type="metadata",
source_path="task.duration_seconds",
value_type="int",
priority=0,
is_enabled=True,
)
]
base_collectors = [
# Should be kept (dimension not present in video_collectors)
DimensionCollector(
api_format="openai:chat",
task_type="video",
dimension_name="resolution",
source_type="metadata",
source_path="task.resolution",
value_type="string",
priority=0,
is_enabled=True,
),
# Should be ignored (dimension already present in video_collectors)
DimensionCollector(
api_format="openai:chat",
task_type="video",
dimension_name="duration_seconds",
source_type="metadata",
source_path="task.duration_seconds",
value_type="int",
priority=0,
is_enabled=True,
),
]
q1 = MagicMock()
q1.filter.return_value.all.return_value = video_collectors
q2 = MagicMock()
q2.filter.return_value.all.return_value = base_collectors
db.query.side_effect = [q1, q2]
svc = DimensionCollectorService(db)
result = svc.list_enabled_collectors(api_format="openai:video", task_type="video")
assert [c.dimension_name for c in result] == ["duration_seconds", "resolution"]
assert "video_size_bytes" in [c.dimension_name for c in result]

View File

@@ -54,7 +54,7 @@ class TestFormulaEngine:
)
assert result.status == "complete"
assert result.missing_required == []
assert abs(result.cost - 0.25) < 1e-9
assert abs(float(result.cost) - 0.25) < 1e-9
def test_required_dimension_missing_non_strict(self) -> None:
engine = FormulaEngine()
@@ -72,7 +72,7 @@ class TestFormulaEngine:
strict_mode=False,
)
assert result.status == "incomplete"
assert result.cost == 0.0
assert float(result.cost) == 0.0
assert result.missing_required == ["duration_seconds"]
def test_required_dimension_missing_strict_raises(self) -> None:
@@ -127,7 +127,7 @@ class TestFormulaEngine:
},
)
assert result.status == "complete"
assert result.cost == 0.0
assert float(result.cost) == 0.0
def test_tiered_mapping(self) -> None:
engine = FormulaEngine()
@@ -148,7 +148,7 @@ class TestFormulaEngine:
},
)
assert result.status == "complete"
assert result.cost == 3.0
assert float(result.cost) == 3.0
result = engine.evaluate(
expression="input_price",
@@ -166,4 +166,4 @@ class TestFormulaEngine:
},
)
assert result.status == "complete"
assert result.cost == 1.5
assert float(result.cost) == 1.5

View File

@@ -0,0 +1,112 @@
from unittest.mock import MagicMock
import pytest
from src.config.settings import config
from src.services.billing.schema import BillingSnapshot, CostResult
from src.services.billing.shadow import CostBreakdown, ShadowBillingService
class TestShadowBillingServiceModeResolution:
def test_get_engine_mode_exact_and_wildcard(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "billing_engine", "legacy", raising=False)
monkeypatch.setattr(
config,
"billing_engine_overrides",
'{"anthropic/*": "shadow", "openai/gpt-4o": "new"}',
raising=False,
)
svc = ShadowBillingService(MagicMock())
assert svc.get_engine_mode("openai", "gpt-4o") == "new"
assert svc.get_engine_mode("anthropic", "claude-3-5-sonnet") == "shadow"
assert svc.get_engine_mode("other", "x") == "legacy"
class TestShadowBillingServiceExecution:
def test_legacy_mode_skips_new_engine(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "billing_engine", "legacy", raising=False)
monkeypatch.setattr(config, "billing_engine_overrides", "{}", raising=False)
svc = ShadowBillingService(MagicMock())
# Guard: if new engine calculate gets called, fail.
svc._new_billing = MagicMock()
svc._new_billing.calculate.side_effect = AssertionError(
"new engine should not run in legacy mode"
)
legacy_truth = CostBreakdown(
input_cost=0.1,
output_cost=0.2,
cache_creation_cost=0.0,
cache_read_cost=0.0,
request_cost=0.0,
total_cost=0.3,
)
res = svc.calculate_with_shadow(
provider="openai",
provider_id="p-1",
model="gpt-4o",
task_type="chat",
api_format="openai:chat",
input_tokens=1,
output_tokens=1,
legacy_truth=legacy_truth,
is_failed_request=False,
)
assert res.engine_mode == "legacy"
assert res.truth_engine == "legacy"
assert res.shadow_snapshot is None
assert res.truth_breakdown.total_cost == 0.3
def test_shadow_mode_returns_snapshot_and_keeps_legacy_truth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(config, "billing_engine", "shadow", raising=False)
monkeypatch.setattr(config, "billing_engine_overrides", "{}", raising=False)
monkeypatch.setattr(config, "billing_diff_threshold_usd", 0.0001, raising=False)
svc = ShadowBillingService(MagicMock())
# Stub new engine output
snapshot = BillingSnapshot(
resolved_dimensions={"input_tokens": 1},
resolved_variables={"input_price_per_1m": "3.0"},
cost_breakdown={"input_cost": 0.003},
total_cost=0.003,
status="complete",
calculated_at="2026-02-02T00:00:00Z",
)
svc._new_billing = MagicMock()
svc._new_billing.calculate.return_value = CostResult(
cost=0.003, status="complete", snapshot=snapshot
)
legacy_truth = CostBreakdown(
input_cost=0.004,
output_cost=0.0,
cache_creation_cost=0.0,
cache_read_cost=0.0,
request_cost=0.0,
total_cost=0.004,
)
res = svc.calculate_with_shadow(
provider="openai",
provider_id="p-1",
model="gpt-4o",
task_type="chat",
api_format="openai:chat",
input_tokens=1,
output_tokens=0,
legacy_truth=legacy_truth,
is_failed_request=False,
)
assert res.engine_mode == "shadow"
assert res.truth_engine == "legacy"
assert res.shadow_snapshot is not None
assert res.truth_breakdown.total_cost == 0.004
assert "diff_usd" in res.comparison

View File

@@ -54,8 +54,7 @@ async def test_build_candidates_allows_cross_format_when_endpoint_accepts_and_ov
client_format="claude:chat",
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=False, # DB 全局覆盖关闭
master_conversion_enabled=True, # ENV 总闸开启(默认)
global_conversion_enabled=True, # 全局开关开启
)
assert len(candidates) == 1
@@ -88,8 +87,7 @@ async def test_build_candidates_blocks_cross_format_when_master_switch_off() ->
client_format="claude:chat",
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=False,
master_conversion_enabled=False,
global_conversion_enabled=False, # 全局开关关闭
)
assert candidates == []
@@ -118,8 +116,7 @@ async def test_build_candidates_includes_cross_format_when_enabled() -> None:
client_format="claude:chat",
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=True, # DB 全局覆盖开启:跳过端点检查
master_conversion_enabled=True,
global_conversion_enabled=True, # 全局开关开启:跳过端点检查
)
assert len(candidates) == 1
@@ -157,8 +154,7 @@ async def test_exact_matches_rank_before_convertible() -> None:
client_format="claude:chat",
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=False,
master_conversion_enabled=True,
global_conversion_enabled=True, # 全局开关开启
)
assert len(candidates) == 2

View File

@@ -62,18 +62,15 @@ async def test_queue_writer_publishes_event(monkeypatch):
old_stream_key = config.usage_queue_stream_key
old_maxlen = config.usage_queue_stream_maxlen
old_include_headers = config.usage_queue_include_headers
old_include_bodies = config.usage_queue_include_bodies
try:
config.usage_queue_stream_key = "usage:events:test"
config.usage_queue_stream_maxlen = 0
config.usage_queue_include_headers = False
config.usage_queue_include_bodies = False
writer = QueueTelemetryWriter(
request_id="req-2",
user_id="user-1",
api_key_id="key-1",
log_level="basic",
)
await writer.record_success(
provider="test",
@@ -86,8 +83,6 @@ async def test_queue_writer_publishes_event(monkeypatch):
finally:
config.usage_queue_stream_key = old_stream_key
config.usage_queue_stream_maxlen = old_maxlen
config.usage_queue_include_headers = old_include_headers
config.usage_queue_include_bodies = old_include_bodies
assert dummy.calls
key, fields, _, _ = dummy.calls[0]
@@ -310,31 +305,28 @@ async def test_queue_writer_include_headers_bodies(monkeypatch):
monkeypatch.setattr("src.services.usage.telemetry_writer.get_redis_client", _get_redis_client)
old_include_headers = config.usage_queue_include_headers
old_include_bodies = config.usage_queue_include_bodies
try:
config.usage_queue_include_headers = True
config.usage_queue_include_bodies = True
writer = QueueTelemetryWriter(
request_id="req-full",
user_id="user-1",
api_key_id="key-1",
)
await writer.record_success(
provider="test",
model="model",
request_headers={"Authorization": "Bearer xxx"},
response_headers={"Content-Type": "application/json"},
request_body={"messages": [{"role": "user", "content": "hi"}]},
response_body={"choices": [{"message": {"content": "hello"}}]},
)
finally:
config.usage_queue_include_headers = old_include_headers
config.usage_queue_include_bodies = old_include_bodies
writer = QueueTelemetryWriter(
request_id="req-full",
user_id="user-1",
api_key_id="key-1",
log_level="full",
sensitive_headers=["authorization"],
max_request_body_size=0,
max_response_body_size=0,
)
await writer.record_success(
provider="test",
model="model",
request_headers={"Authorization": "Bearer xxx"},
response_headers={"Content-Type": "application/json"},
request_body={"messages": [{"role": "user", "content": "hi"}]},
response_body={"choices": [{"message": {"content": "hello"}}]},
)
event = UsageEvent.from_stream_fields(dummy.calls[0][1])
assert event.data["request_headers"]["Authorization"] == "Bearer xxx"
# Sensitive header should be masked before going into Redis
assert event.data["request_headers"]["Authorization"].startswith("Bear")
assert "****" in event.data["request_headers"]["Authorization"]
assert "request_body" in event.data
assert "response_body" in event.data
@@ -378,31 +370,26 @@ async def test_queue_writer_body_truncation(monkeypatch):
monkeypatch.setattr("src.services.usage.telemetry_writer.get_redis_client", _get_redis_client)
old_include_bodies = config.usage_queue_include_bodies
old_max_bytes = config.usage_queue_body_max_bytes
try:
config.usage_queue_include_bodies = True
config.usage_queue_body_max_bytes = 50
writer = QueueTelemetryWriter(
request_id="req-trunc",
user_id="user-1",
api_key_id="key-1",
)
long_body = {"content": "x" * 1000}
await writer.record_success(
provider="test",
model="model",
request_body=long_body,
)
finally:
config.usage_queue_include_bodies = old_include_bodies
config.usage_queue_body_max_bytes = old_max_bytes
writer = QueueTelemetryWriter(
request_id="req-trunc",
user_id="user-1",
api_key_id="key-1",
log_level="full",
max_request_body_size=50,
max_response_body_size=0,
)
long_body = {"content": "x" * 1000}
await writer.record_success(
provider="test",
model="model",
request_body=long_body,
)
event = UsageEvent.from_stream_fields(dummy.calls[0][1])
body_str = event.data["request_body"]
assert len(body_str) <= 50
assert body_str.endswith("...[truncated]")
body = event.data["request_body"]
assert isinstance(body, dict)
assert body.get("_truncated") is True
assert len(body.get("_content") or "") <= 50
@pytest.mark.asyncio

203
tests/test_sora.py Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Sora Video Generation Test Script
This script demonstrates video generation using OpenAI's Sora API.
It sends a request to generate a video, polls for completion, and downloads the result.
Usage:
export OPENAI_API_KEY="your-api-key"
python test_sora.py
"""
from __future__ import annotations
import os
import sys
import time
import requests
# OpenAI API Base URL
BASE_URL = "http://localhost:8084/v1"
# Default polling interval in seconds
POLL_INTERVAL = 10
os.environ["OPENAI_API_KEY"] = "sk-PCr5oXZNKb9HcyzYqTIMvr8zXsIBK3WS"
def generate_video(
api_key: str,
prompt: str,
model: str = "sora-2",
size: str = "1920x1080",
duration: int = 10,
n: int = 1,
) -> str:
"""
Send a request to generate a video and return the video job ID.
Args:
api_key: OpenAI API key
prompt: Text prompt for video generation
model: Model name to use (default: sora-2)
size: Video resolution (default: 1920x1080)
duration: Video duration in seconds (default: 10)
n: Number of videos to generate (default: 1)
Returns:
Video job ID for polling status
"""
url = f"{BASE_URL}/videos"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"prompt": prompt,
"size": size,
"duration": str(duration),
"n": n,
}
print(f"Sending video generation request to {model}...")
print(f" Size: {size}, Duration: {duration}s")
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
video_id = data.get("id")
if not video_id:
raise ValueError(f"No video ID in response: {data}")
print(f"Video job started: {video_id}")
print(f" Status: {data.get('status')}")
return video_id
def poll_video(api_key: str, video_id: str, poll_interval: int = POLL_INTERVAL) -> dict:
"""
Poll the video job status until the video is ready.
Args:
api_key: OpenAI API key
video_id: Video job ID from generate_video
poll_interval: Seconds between polls (default: 10)
Returns:
Final response dict containing the video metadata
"""
url = f"{BASE_URL}/videos/{video_id}"
headers = {
"Authorization": f"Bearer {api_key}",
}
print(f"Polling video job status (every {poll_interval}s)...")
while True:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status()
data = response.json()
status = data.get("status", "unknown")
progress = data.get("progress", 0)
print(f" Status: {status}, Progress: {progress}%")
if status == "completed":
print("Video generation completed!")
print(f" Duration: {data.get('seconds')}s")
print(f" Size: {data.get('size')}")
print(f" Expires at: {data.get('expires_at')}")
return data
if status == "failed":
error = data.get("error", {})
raise RuntimeError(f"Video generation failed: {error.get('message', error)}")
if status == "cancelled":
raise RuntimeError("Video generation was cancelled")
time.sleep(poll_interval)
def download_video(api_key: str, video_id: str, output_path: str = "sora_output.mp4", variant: str | None = None) -> str:
"""
Download the generated video content.
Args:
api_key: OpenAI API key
video_id: Video job ID
output_path: Path to save the video (default: sora_output.mp4)
variant: Optional variant to download (defaults to MP4 video)
Returns:
Path to the downloaded video
"""
url = f"{BASE_URL}/videos/{video_id}/content"
if variant:
url = f"{url}?variant={variant}"
headers = {
"Authorization": f"Bearer {api_key}",
}
print(f"Downloading video content...")
response = requests.get(url, headers=headers, allow_redirects=True, timeout=300, stream=True)
response.raise_for_status()
with open(output_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
file_size = os.path.getsize(output_path)
print(f"Video saved to: {output_path} ({file_size / 1024 / 1024:.2f} MB)")
return output_path
def main() -> None:
"""Main entry point."""
# Get API key from environment
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OPENAI_API_KEY environment variable not set", file=sys.stderr)
print("Usage: export OPENAI_API_KEY='your-api-key' && python test_sora.py", file=sys.stderr)
sys.exit(1)
# Default prompt
prompt = "A calico cat playing a piano on stage"
# Allow custom prompt via command line argument
if len(sys.argv) > 1:
prompt = " ".join(sys.argv[1:])
print(f"Using custom prompt: {prompt}")
try:
# Step 1: Start video generation
video_id = generate_video(api_key, prompt)
# Step 2: Poll until complete
final_response = poll_video(api_key, video_id)
# Step 3: Download video content
download_video(api_key, video_id)
print("\nVideo generation complete!")
print(f"Video ID: {video_id}")
print(f"Model: {final_response.get('model')}")
except requests.exceptions.HTTPError as e:
print(f"HTTP Error: {e}", file=sys.stderr)
if e.response is not None:
print(f"Response: {e.response.text}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

207
tests/test_veo.py Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Veo Video Generation Test Script
This script demonstrates video generation using Google's Veo API.
It sends a request to generate a video, polls for completion, and downloads the result.
Usage:
export GEMINI_API_KEY="your-api-key"
python test_veo.py
"""
from __future__ import annotations
import os
import sys
import time
import requests
# Gemini API Base URL
BASE_URL = "http://localhost:8084/v1beta"
# Default polling interval in seconds
POLL_INTERVAL = 10
os.environ["GEMINI_API_KEY"] = "sk-PCr5oXZNKb9HcyzYqTIMvr8zXsIBK3WS"
def generate_video(api_key: str, prompt: str, model: str = "veo-3.1-fast-generate-preview") -> str:
"""
Send a request to generate a video and return the operation name.
Args:
api_key: Gemini API key
prompt: Text prompt for video generation
model: Model name to use (default: veo-3.1-generate-preview)
Returns:
Operation name for polling status
"""
url = f"{BASE_URL}/models/{model}:predictLongRunning"
headers = {
"x-goog-api-key": api_key,
"Content-Type": "application/json",
}
payload = {
"instances": [
{
"prompt": prompt,
}
]
}
print(f"Sending video generation request to {model}...")
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
operation_name = data.get("name")
if not operation_name:
raise ValueError(f"No operation name in response: {data}")
print(f"Operation started: {operation_name}")
return operation_name
def poll_operation(api_key: str, operation_name: str, poll_interval: int = POLL_INTERVAL) -> dict:
"""
Poll the operation status until the video is ready.
Args:
api_key: Gemini API key
operation_name: Operation name from generate_video
poll_interval: Seconds between polls (default: 10)
Returns:
Final response dict containing the video URI
"""
url = f"{BASE_URL}/{operation_name}"
headers = {
"x-goog-api-key": api_key,
}
print(f"Polling operation status (every {poll_interval}s)...")
while True:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status()
data = response.json()
is_done = data.get("done", False)
if is_done:
print("Operation completed!")
# Check for errors
if "error" in data:
error = data["error"]
raise RuntimeError(f"Operation failed: {error.get('message', error)}")
return data
# Show progress if available
metadata = data.get("metadata", {})
if metadata:
progress = metadata.get("progress", "unknown")
print(f" Progress: {progress}%")
time.sleep(poll_interval)
def download_video(api_key: str, video_uri: str, output_path: str = "dialogue_example.mp4") -> str:
"""
Download the generated video.
Args:
api_key: Gemini API key
video_uri: URI of the generated video
output_path: Path to save the video (default: dialogue_example.mp4)
Returns:
Path to the downloaded video
"""
headers = {
"x-goog-api-key": api_key,
}
print(f"Downloading video from: {video_uri}")
response = requests.get(video_uri, headers=headers, allow_redirects=True, timeout=300, stream=True)
response.raise_for_status()
with open(output_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
file_size = os.path.getsize(output_path)
print(f"Video saved to: {output_path} ({file_size / 1024 / 1024:.2f} MB)")
return output_path
def extract_video_uri(response: dict) -> str:
"""
Extract the video URI from the operation response.
Args:
response: Final operation response dict
Returns:
Video download URI
"""
try:
video_response = response["response"]["generateVideoResponse"]
samples = video_response["generatedSamples"]
video_uri = samples[0]["video"]["uri"]
return video_uri
except (KeyError, IndexError) as e:
raise ValueError(f"Could not extract video URI from response: {response}") from e
def main() -> None:
"""Main entry point."""
# Get API key from environment
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
print("Error: GEMINI_API_KEY environment variable not set", file=sys.stderr)
print("Usage: export GEMINI_API_KEY='your-api-key' && python test_veo.py", file=sys.stderr)
sys.exit(1)
# Default prompt (same as the bash script)
prompt = (
"A close up of two people staring at a cryptic drawing on a wall, "
"torchlight flickering. A man murmurs, \"This must be it. That's the secret code.\" "
"The woman looks at him and whispering excitedly, \"What did you find?\""
)
# Allow custom prompt via command line argument
if len(sys.argv) > 1:
prompt = " ".join(sys.argv[1:])
print(f"Using custom prompt: {prompt}")
try:
# Step 1: Start video generation
operation_name = generate_video(api_key, prompt)
# Step 2: Poll until complete
final_response = poll_operation(api_key, operation_name)
# Step 3: Extract video URI and download
video_uri = extract_video_uri(final_response)
download_video(api_key, video_uri)
print("\nVideo generation complete!")
except requests.exceptions.HTTPError as e:
print(f"HTTP Error: {e}", file=sys.stderr)
if e.response is not None:
print(f"Response: {e.response.text}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()