fix(xai): bill video creation for the submitted duration and resolution

The xAI create response only carries request_id, so usage was empty and
completion_cost priced every created video at zero seconds. Derive
usage.duration_seconds and usage.video_resolution from the request body
sent to xAI, and forward the resolution param that was being dropped.

Also cover the remaining image edit/generation, video, and ownership
behaviors that had no tests.
This commit is contained in:
hx 2026-10-15 19:41:54 +08:00
parent 3c12c514dd
commit 2c313b6fd0
9 changed files with 397 additions and 18 deletions

View file

@ -1,3 +1,4 @@
import math
import time
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final
@ -52,6 +53,28 @@ def _duration_from_seconds(seconds: object) -> int:
return 6
def _positive_seconds(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return None
try:
seconds: Final = float(value)
except ValueError:
return None
return seconds if math.isfinite(seconds) and seconds > 0 else None
def _requested_video_usage(request_data: Mapping[str, object]) -> dict[str, object]: # mutable-ok: VideoObject.usage
resolution: Final = request_data.get("resolution") or request_data.get("resolution_name")
return {
key: value
for key, value in (
("duration_seconds", _positive_seconds(request_data.get("duration"))),
("video_resolution", resolution if isinstance(resolution, str) and resolution else None),
)
if value is not None
}
_STATUS_MAP: Final = {
"done": "completed",
"completed": "completed",
@ -206,6 +229,7 @@ class XAIVideoConfig(BaseVideoConfig):
"image",
"images",
"duration",
"resolution",
"resolution_name",
"aspect_ratio",
"size",
@ -238,7 +262,9 @@ class XAIVideoConfig(BaseVideoConfig):
if not request_id:
raise ValueError(f"xAI video generation response missing request_id: {response_data}")
usage: Final = response_data.get("usage") or {}
provider_usage: Final[object] = response_data.get("usage")
requested_usage: Final = _requested_video_usage(request_data or {})
duration_seconds: Final = requested_usage.get("duration_seconds")
video_obj: Final = VideoObject(
id=str(request_id),
object="video",
@ -246,11 +272,11 @@ class XAIVideoConfig(BaseVideoConfig):
created_at=int(time.time()),
model=XAIModelInfo.get_base_model(model) or model,
progress=0,
seconds=f"{duration_seconds:g}" if isinstance(duration_seconds, float) else None,
)
if custom_llm_provider:
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model)
usage_body: Final = usage if isinstance(usage, dict) else None
video_obj.usage = usage_body or {}
video_obj.usage = {**requested_usage, **(provider_usage if _is_json_object(provider_usage) else {})}
return video_obj
def _video_resource_url(self, api_base: str, video_id: str) -> str:
@ -301,7 +327,7 @@ class XAIVideoConfig(BaseVideoConfig):
seconds=seconds,
usage=response_data.get("usage") if isinstance(response_data.get("usage"), dict) else {},
)
video_obj._hidden_params["video_url"] = video_url # pyright: ignore[reportPrivateUsage] # no public setter
video_obj.hidden_params["video_url"] = video_url
if custom_llm_provider and video_obj.id and video_obj.id != "unknown":
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, response_data.get("model"))
return video_obj

View file

@ -11,7 +11,7 @@ from litellm.llms.xai.batches.transformation import XAIBatchesError
from litellm.types.utils import LiteLLMBatch
API_BASE: Final = "https://api.x.ai"
KEY: Final = "xai-test-key"
KEY: Final = "fake-key"
@pytest.fixture(autouse=True)

View file

@ -10,7 +10,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.openai import OpenAIFileObject
API_BASE: Final = "https://api.x.ai"
KEY: Final = "xai-test-key"
KEY: Final = "fake-key"
@pytest.fixture(autouse=True)

View file

@ -199,7 +199,7 @@ def test_completion_strips_message_names_from_request(respx_mock: respx.MockRout
{"role": "assistant", "content": "OK", "name": "responder"},
{"role": "user", "content": "Again", "name": "caller"},
],
api_key="xai-test-key",
api_key="fake-key",
api_base=XAI_API_BASE,
)
@ -274,7 +274,7 @@ def test_streaming_include_usage_surfaces_xai_final_usage_chunk(
messages=[{"role": "user", "content": "Say OK"}],
stream=True,
stream_options={"include_usage": True},
api_key="xai-test-key",
api_key="fake-key",
api_base=XAI_API_BASE,
)
)

View file

@ -20,7 +20,7 @@ def _default_xai_api_base(monkeypatch: pytest.MonkeyPatch):
def _get_models(payload: object) -> list[str]:
with respx.mock:
respx.get(MODELS_URL).mock(return_value=httpx.Response(200, json=payload))
return XAIModelInfo().get_models(api_key="xai-test-key")
return XAIModelInfo().get_models(api_key="fake-key")
def test_get_models_prefixes_every_listed_model_id_in_provider_order():
@ -47,9 +47,9 @@ def test_get_models_returns_no_names_for_an_empty_data_container(data: object):
def test_get_models_sends_the_api_key_as_bearer_token():
with respx.mock:
route = respx.get(MODELS_URL).mock(return_value=httpx.Response(200, json={"data": [{"id": "grok-4"}]}))
XAIModelInfo().get_models(api_key="xai-test-key")
XAIModelInfo().get_models(api_key="fake-key")
assert route.calls[0].request.headers["authorization"] == "Bearer xai-test-key"
assert route.calls[0].request.headers["authorization"] == "Bearer fake-key"
@pytest.mark.parametrize(
@ -101,7 +101,7 @@ def test_get_models_reports_the_upstream_status_and_body_on_an_http_error():
with pytest.raises(
Exception, match=re.escape("Failed to fetch models from XAI. Status code: 401, Response: bad key")
):
XAIModelInfo().get_models(api_key="xai-test-key")
XAIModelInfo().get_models(api_key="fake-key")
@pytest.mark.parametrize(

View file

@ -1,4 +1,5 @@
import base64
from io import BytesIO
from unittest.mock import MagicMock, patch
import httpx
@ -184,3 +185,135 @@ def test_transform_multiple_images_uses_images_array():
{"url": "https://imgen.x.ai/a.jpeg"},
{"url": "https://imgen.x.ai/b.jpeg"},
]
def _edit_request(image, optional_params=None):
from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig
data, _ = XAIImageEditConfig().transform_image_edit_request(
model="grok-imagine-image",
prompt="make it night",
image=image,
image_edit_optional_request_params=optional_params or {},
litellm_params=GenericLiteLLMParams(),
headers={},
)
return data
def test_validate_environment_api_key_sets_bearer_and_keeps_caller_content_type():
from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig
headers = XAIImageEditConfig().validate_environment(
headers={"Content-Type": "application/custom"},
model="grok-imagine-image",
api_key="fake-key",
litellm_params={},
)
assert headers == {"Content-Type": "application/custom", "Authorization": "Bearer fake-key"}
def test_map_rejects_unknown_param_unless_dropped():
from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig
config = XAIImageEditConfig()
with pytest.raises(ValueError, match="Parameter quality is not supported"):
config.map_openai_params(
image_edit_optional_params={"quality": "hd", "n": 2},
model="grok-imagine-image",
drop_params=False,
)
assert config.map_openai_params(
image_edit_optional_params={"quality": "hd", "n": 2, "resolution": "2k"},
model="grok-imagine-image",
drop_params=True,
) == {"n": 2, "resolution": "2k"}
def test_get_complete_url_appends_v1_to_a_bare_custom_base():
from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig
url = XAIImageEditConfig().get_complete_url(
model="grok-imagine-image",
api_base="https://xai-gateway.example.com/",
litellm_params={"api_key": "fake-key"},
)
assert url == "https://xai-gateway.example.com/v1/images/edits"
@pytest.mark.parametrize("image", [None, [], [None]])
def test_transform_rejects_missing_reference_image(image):
with pytest.raises(ValueError, match="requires at least one reference image"):
_edit_request(image)
@pytest.mark.parametrize(
"image,expected",
[
({"url": "https://imgen.x.ai/a.jpeg"}, {"url": "https://imgen.x.ai/a.jpeg"}),
({"file_id": "file-123"}, {"file_id": "file-123"}),
],
)
def test_transform_passes_url_and_file_id_references_through(image, expected):
assert _edit_request(image)["image"] == expected
@pytest.mark.parametrize(
"image",
[
bytearray(b"\x89PNG\r\n\x1a\nfakepng"),
BytesIO(b"\x89PNG\r\n\x1a\nfakepng"),
],
)
def test_transform_encodes_binary_inputs_as_data_uri(image):
expected = "data:image/png;base64," + base64.b64encode(b"\x89PNG\r\n\x1a\nfakepng").decode()
assert _edit_request(image)["image"]["url"] == expected
def test_transform_reads_a_partially_consumed_stream_from_the_start_and_restores_position():
stream = BytesIO(b"\x89PNG\r\n\x1a\nfakepng")
stream.seek(4)
url = _edit_request(stream)["image"]["url"]
assert url.endswith(base64.b64encode(b"\x89PNG\r\n\x1a\nfakepng").decode())
assert stream.tell() == 4
def test_transform_encodes_text_reader_as_utf8():
class TextReader:
def read(self) -> str:
return "plain-text-bytes"
url = _edit_request(TextReader())["image"]["url"]
assert url.endswith(base64.b64encode(b"plain-text-bytes").decode())
def test_transform_rejects_unsupported_image_type():
with pytest.raises(ValueError, match="Unsupported image input type"):
_edit_request(12345)
@pytest.mark.parametrize(
"raw",
[
httpx.Response(502, text="<html>bad gateway</html>"),
httpx.Response(200, json={"data": []}),
],
)
def test_transform_response_raises_provider_error_without_images(raw):
from litellm.llms.xai.image_edit.transformation import XAIImageEditConfig
with pytest.raises(Exception) as exc_info:
XAIImageEditConfig().transform_image_edit_response(
model="grok-imagine-image",
raw_response=raw,
logging_obj=MagicMock(),
)
assert getattr(exc_info.value, "status_code", None) == raw.status_code

View file

@ -127,3 +127,69 @@ def test_transform_request_and_response():
)
assert response.data is not None
assert response.data[0].url == "https://imgen.x.ai/example.jpeg"
def test_validate_environment_api_key_sets_bearer_and_json_content_type():
headers = XAIImageGenerationConfig().validate_environment(
headers={},
model="grok-imagine-image",
messages=[],
optional_params={},
litellm_params={},
api_key="fake-key",
)
assert headers == {"Authorization": "Bearer fake-key", "Content-Type": "application/json"}
def test_map_rejects_unknown_param_unless_dropped():
config = XAIImageGenerationConfig()
with pytest.raises(ValueError, match="Parameter quality is not supported"):
config.map_openai_params(
non_default_params={"quality": "hd", "n": 2},
optional_params={},
model="grok-imagine-image",
drop_params=False,
)
assert config.map_openai_params(
non_default_params={"quality": "hd", "n": 2},
optional_params={},
model="grok-imagine-image",
drop_params=True,
) == {"n": 2}
def test_get_complete_url_appends_v1_to_a_bare_custom_base():
url = XAIImageGenerationConfig().get_complete_url(
api_base="https://xai-gateway.example.com/",
api_key="fake-key",
model="grok-imagine-image",
optional_params={},
litellm_params={},
)
assert url == "https://xai-gateway.example.com/v1/images/generations"
@pytest.mark.parametrize(
"raw",
[
httpx.Response(502, text="<html>bad gateway</html>"),
httpx.Response(200, json={"data": ["junk"]}),
],
)
def test_transform_response_raises_provider_error_without_images(raw):
with pytest.raises(Exception) as exc_info:
XAIImageGenerationConfig().transform_image_generation_response(
model="grok-imagine-image",
raw_response=raw,
model_response=ImageResponse(),
logging_obj=MagicMock(),
request_data={"prompt": "a red apple"},
optional_params={},
litellm_params={},
encoding=None,
)
assert getattr(exc_info.value, "status_code", None) == raw.status_code

View file

@ -40,6 +40,46 @@ def test_map_seconds_nine_is_not_clamped_to_six():
assert "seconds" not in mapped
def test_map_non_numeric_seconds_falls_back_to_the_default_duration():
config = XAIVideoConfig()
defaulted = config.map_openai_params(
video_create_optional_params={"seconds": "ten"},
model="grok-imagine-video-1.5",
drop_params=True,
)
request_body, _, _ = config.transform_video_create_request(
model="grok-imagine-video-1.5",
prompt="a cat walking",
api_base="https://api.x.ai/v1/videos/generations",
video_create_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert defaulted["duration"] == request_body["duration"]
def test_get_complete_url_appends_v1_to_a_bare_custom_base():
url = XAIVideoConfig().get_complete_url(
model="grok-imagine-video",
api_base="https://xai-gateway.example.com/",
litellm_params={"api_key": "fake-key"},
)
assert url == "https://xai-gateway.example.com/v1/videos/generations"
def test_create_response_without_request_id_raises():
with pytest.raises(ValueError, match="missing request_id"):
XAIVideoConfig().transform_video_create_response(
model="grok-imagine-video",
raw_response=httpx.Response(200, json={"error": "queued nowhere"}),
logging_obj=MagicMock(),
custom_llm_provider="xai",
request_data={"duration": 6},
)
def test_get_complete_url_create_and_status_root():
config = XAIVideoConfig()
create_url = config.get_complete_url(
@ -120,7 +160,109 @@ def test_transform_create_and_status_response():
)
assert status.status == "completed"
assert status.seconds == "6"
assert status._hidden_params.get("video_url") == "https://vidgen.x.ai/x.mp4"
assert status.hidden_params.get("video_url") == "https://vidgen.x.ai/x.mp4"
def _created_video_cost(optional_params: dict[str, object], model: str = "grok-imagine-video-1.5") -> tuple[float, str]:
config = XAIVideoConfig()
request_body, _, _ = config.transform_video_create_request(
model=model,
prompt="a cat walking",
api_base="https://api.x.ai/v1/videos/generations",
video_create_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
created = config.transform_video_create_response(
model=model,
raw_response=httpx.Response(200, json={"request_id": "req-123"}),
logging_obj=MagicMock(),
custom_llm_provider="xai",
request_data=request_body,
)
cost = litellm.completion_cost(
completion_response=created,
model=f"xai/{model}",
custom_llm_provider="xai",
call_type="create_video",
)
return cost, str(created.seconds)
@pytest.mark.parametrize(
"optional_params,expected_seconds,rate_key",
[
({"duration": 10}, 10, "output_cost_per_second"),
({"duration": "4", "resolution": "720p"}, 4, "output_cost_per_second_720p"),
],
)
def test_create_response_bills_the_submitted_duration_and_resolution(optional_params, expected_seconds, rate_key):
model_cost = litellm.model_cost["xai/grok-imagine-video-1.5"]
assert model_cost["output_cost_per_second_720p"] != model_cost["output_cost_per_second"]
cost, seconds = _created_video_cost(optional_params)
assert seconds == str(expected_seconds)
assert cost > 0
assert cost == pytest.approx(expected_seconds * model_cost[rate_key])
def test_create_response_bills_the_default_duration_sent_to_xai():
config = XAIVideoConfig()
request_body, _, _ = config.transform_video_create_request(
model="grok-imagine-video-1.5",
prompt="a cat walking",
api_base="https://api.x.ai/v1/videos/generations",
video_create_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
cost, seconds = _created_video_cost({})
assert seconds == str(request_body["duration"])
assert cost == pytest.approx(
request_body["duration"] * litellm.model_cost["xai/grok-imagine-video-1.5"]["output_cost_per_second"]
)
def test_create_request_forwards_resolution_to_xai():
request_body, _, _ = XAIVideoConfig().transform_video_create_request(
model="grok-imagine-video-1.5",
prompt="a cat walking",
api_base="https://api.x.ai/v1/videos/generations",
video_create_optional_request_params={"resolution": "1080p"},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert request_body["resolution"] == "1080p"
def test_create_response_keeps_provider_reported_usage_over_the_request():
created = XAIVideoConfig().transform_video_create_response(
model="grok-imagine-video-1.5",
raw_response=httpx.Response(200, json={"request_id": "req-123", "usage": {"duration_seconds": 3.0}}),
logging_obj=MagicMock(),
custom_llm_provider="xai",
request_data={"duration": 10, "resolution": "720p"},
)
assert created.usage == {"duration_seconds": 3.0, "video_resolution": "720p"}
@pytest.mark.parametrize("duration", [None, 0, -5, True, "abc", "nan"])
def test_create_response_omits_unusable_durations_from_usage(duration):
created = XAIVideoConfig().transform_video_create_response(
model="grok-imagine-video-1.5",
raw_response=httpx.Response(200, json={"request_id": "req-123"}),
logging_obj=MagicMock(),
custom_llm_provider="xai",
request_data={"duration": duration},
)
assert created.usage == {}
assert created.seconds is None
def test_sync_video_status_through_the_http_handler():
@ -131,9 +273,7 @@ def test_sync_video_status_through_the_http_handler():
request=httpx.Request("GET", "https://api.x.ai/v1/videos/req-123"),
)
status = litellm.video_status(
video_id="req-123", model="xai/grok-imagine-video", api_key="test-key", client=client
)
status = litellm.video_status(video_id="req-123", model="xai/grok-imagine-video", api_key="test-key", client=client)
assert status.status == "completed"
assert client.get.call_args.kwargs["url"] == "https://api.x.ai/v1/videos/req-123"

View file

@ -19,6 +19,7 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth, hash_token
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.video_endpoints import endpoints
from litellm.types.videos.main import VideoObject
from litellm.types.videos.utils import encode_video_id_with_provider
ALICE = UserAPIKeyAuth(user_id="alice", api_key=hash_token("sk-alice"))
@ -70,11 +71,13 @@ class Provider:
def __init__(self):
self.calls: list[str] = []
self.returns_video_objects = False
async def respond(self, processor, *, route_type: str, **kwargs):
self.calls.append(route_type)
if route_type in ("avideo_generation", "avideo_remix", "avideo_edit", "avideo_extension"):
return {"id": f"video_{uuid.uuid4().hex}", "object": "video", "status": "queued"}
created = {"id": f"video_{uuid.uuid4().hex}", "object": "video", "status": "queued"}
return VideoObject(**created) if self.returns_video_objects else created
if route_type == "avideo_content":
return b"mp4-bytes"
if route_type == "avideo_list":
@ -132,11 +135,22 @@ ALL_OK = {"status": 200, "content": 200, "remix": 200, "edit": 200, "extension":
ALL_FORBIDDEN = {"status": 403, "content": 403, "remix": 403, "edit": 403, "extension": 403}
def test_owner_can_use_every_video_route(provider, table):
@pytest.mark.parametrize("returns_video_objects", [False, True])
def test_owner_can_use_every_video_route(provider, table, returns_video_objects):
provider.returns_video_objects = returns_video_objects
video_id = _create_video(ALICE)
assert table.rows[f"video:{video_id}"].created_by == "alice"
assert _access(ALICE, video_id) == ALL_OK
assert _access(BOB, video_id) == ALL_FORBIDDEN
def test_caller_without_identity_scope_leaves_the_video_admin_only(provider, table):
video_id = _create_video(UserAPIKeyAuth())
assert table.rows == {}
assert _access(ALICE, video_id) == ALL_FORBIDDEN
assert _access(ADMIN, video_id) == ALL_OK
def test_other_user_is_forbidden_and_the_provider_is_never_called(provider, table):