Skip to content

fix: accept URL-string image input on the Gemini Veo video route (NOL-252) - #34

Merged
blackflame007 merged 2 commits into
litellm_internal_stagingfrom
litellm_nol252_veo_i2v_url_image
Aug 1, 2026
Merged

fix: accept URL-string image input on the Gemini Veo video route (NOL-252)#34
blackflame007 merged 2 commits into
litellm_internal_stagingfrom
litellm_nol252_veo_i2v_url_image

Conversation

@blackflame007

@blackflame007 blackflame007 commented Aug 1, 2026

Copy link
Copy Markdown

TLDR

Problem this solves:

  • Every Veo 3.1 image-to-video submission carrying a start or reference image failed, and the agent pipeline fell back to Kling on all four video runs of the 2026-07-31 preset sweep
  • The failure was recorded as "Veo 422-rejects reference images", which is wrong; Google was never contacted. The request died inside the proxy during transformation
  • The platform sends the start frame as a signed HTTPS URL in input_reference. GeminiVideoConfig.map_openai_params maps input_reference to image, and transform_video_create_request passed that string to _convert_image_to_gemini_format, which expects a file-like object and calls .read() on it

How it solves it:

  • An http(s) string image is now handed to the image_url branch that already lives in the same transform, which downloads it and inlines it as bytesBase64Encoded
  • A string that is not an http(s) URL raises a ValueError naming the problem instead of an opaque AttributeError; the Vertex video config already guards this same case
  • Dict and file-like image inputs are untouched

The traceback below is verbatim from the logged upstream response body in production:

File "litellm/llms/gemini/videos/transformation.py", line 307, in transform_video_create_request
    image_data = _convert_image_to_gemini_format(image)
File "litellm/llms/gemini/videos/transformation.py", line 67, in _convert_image_to_gemini_format
    image_bytes = image_file.read()
AttributeError: 'str' object has no attribute 'read'

That surfaced as APIConnectionError, then HTTP 400 from the proxy, which the platform re-emitted to its own callers as a generic 422. Kling was unaffected because the fal transform accepts a URL verbatim

Relevant issues

Linear ticket

Resolves NOL-252

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

A real-provider run is deliberately not included. The organization is under a hard spend freeze on generation calls, so this was proven against a local proxy pointed at a stub upstream on 127.0.0.1:9099 that records the request body Veo would have received. The runbook for a maintainer to repeat it against real Veo is in the QA runbook section below

Proxy started from this branch with a config whose only model is veo-3.1-fast mapped to gemini/veo-3.1-fast-generate-preview with api_base: http://127.0.0.1:9099

curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:4000/v1/videos \
  -H "Authorization: Bearer sk-nol252-local" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "veo-3.1-fast",
    "prompt": "Creator lifts the lid off the box and reacts",
    "input_reference": "http://127.0.0.1:9099/images/start.png",
    "image_url": "http://127.0.0.1:9099/images/start.png",
    "image_urls": ["http://127.0.0.1:9099/images/ref-a.png","http://127.0.0.1:9099/images/ref-b.png"],
    "aspect_ratio": "9:16",
    "seconds": "8",
    "generate_audio": true,
    "resolution": "720p"
  }'

Output on this branch:

{"id":"video_bGl0ZWxsbTpjdXN0b21fbGxtX3Byb3ZpZGVyOmdlbWluaTttb2RlbF9pZDp2ZW8tMy4xLWZhc3QtZ2VuZXJhdGUtcHJldmlldzt2aWRlb19pZDpvcGVyYXRpb25zL2dlbmVyYXRlX21vY2sxMjM=","object":"video","status":"processing","created_at":null,"completed_at":null,"expires_at":null,"error":null,"progress":null,"remixed_from_video_id":null,"seconds":null,"size":null,"model":"veo-3.1-fast","usage":{"duration_seconds":8.0,"video_resolution":"720p"}}
HTTP 200

The body the proxy sent upstream, as recorded by the stub:

{"path": "/v1beta/models/veo-3.1-fast-generate-preview:predictLongRunning",
 "instance_keys": ["image", "prompt", "referenceImages"],
 "image_field": {"bytesBase64Encoded": "<base64 96 chars>", "mimeType": "image/png"},
 "n_reference_images": 2,
 "parameters": {"aspectRatio": "9:16", "durationSeconds": 8, "resolution": "720p"}}

The start frame arrives inline as base64 with a sniffed MIME type, and the two reference images land on the instance as referenceImages. On litellm_internal_staging the same request never reaches the upstream at all; it raises the AttributeError shown above during transformation

One thing this makes visible: generate_audio: true went in and does not appear in parameters. GeminiVideoGenerationParameters declares no such field and ignores extras, so it is silently dropped. personGeneration is likewise never populated, though the field's own docstring says image-bearing Veo 3.x runs accept allow_adult only. Both are out of scope here because they change request semantics rather than fixing a crash, and both are tracked separately

Type

Bug Fix

Changes

litellm/llms/gemini/videos/transformation.py gains a string branch in the image handling of transform_video_create_request. There is no new helper and no new dict construction, so the type-discipline budget is unchanged

tests/test_litellm/llms/gemini/videos/test_gemini_video_transformation.py gains three regression tests, all with mocked HTTP and no live provider calls. The first sends input_reference as a signed URL through map_openai_params into the transform and asserts it lands as inline base64. The second reproduces the exact production payload, where the start frame is mirrored into both input_reference and image_url alongside three image_urls references, and asserts the start frame is downloaded exactly once while the references become referenceImages. The third asserts the non-URL string path raises ValueError

The tests were verified to be load-bearing: with the source change reverted, all three fail with the same AttributeError at transformation.py:67 seen in production

QA runbook

To repeat the proof against real Veo rather than the stub, which does cost money:

  1. Put a real GEMINI_API_KEY in .env and drop the api_base override so the route resolves to https://generativelanguage.googleapis.com
  2. Start the proxy: python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log
  3. Run the curl above with input_reference set to any publicly reachable image URL, dropping image_urls for the cheapest possible check
  4. Expect HTTP 200 with a video_ id and status: processing, then poll GET /v1/videos/{id} until completed
  5. Confirm in litellm.log that the outbound instance carries image.bytesBase64Encoded and that no AttributeError appears

Note that step 4 is the first genuine Google response this route will ever have received, so a provider-side rejection there would be new information rather than a regression from this PR

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@blacksmith-sh

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68f983ef82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


def _image_string_to_gemini_format(image: str) -> Dict[str, str]:
if image.startswith(("http://", "https://")):
base64_data, mime_type = fetch_image_as_base64(image)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate remote image URLs before fetching

When an authenticated proxy caller supplies input_reference as a URL, this new branch performs a server-side GET without the repository's SSRF validation, allowing requests to loopback, cloud metadata, or other private-network services (including through redirects). Use litellm.litellm_core_utils.url_utils.safe_get, which validates every redirect hop, rather than routing the user-controlled URL directly through module_level_client.get.

Useful? React with 👍 / 👎.


def _image_string_to_gemini_format(image: str) -> Dict[str, str]:
if image.startswith(("http://", "https://")):
base64_data, mime_type = fetch_image_as_base64(image)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the MIME type from downloaded image bytes

When the signed image URL returns a generic Content-Type such as application/octet-stream, or omits the header for a non-JPEG image, this new input_reference path forwards the generic type or labels the content as JPEG even when the downloaded bytes are PNG/WebP. The resulting instances[0].image.mimeType can therefore be unsupported or disagree with the payload and cause Veo to reject an otherwise valid reference image; detect the image type from response.content as the file-like path already does rather than trusting the response header.

Useful? React with 👍 / 👎.


def _image_string_to_gemini_format(image: str) -> Dict[str, str]:
if image.startswith(("http://", "https://")):
base64_data, mime_type = fetch_image_as_base64(image)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the image download off the async event loop

For avideo_generation and the proxy video route, async_video_generation_handler invokes this synchronous transform directly on the event-loop thread before reaching its asynchronous provider POST. A slow or unresponsive input_reference URL therefore blocks the shared event loop inside module_level_client.get, delaying unrelated proxy requests for the duration of the download; prefetch the image with an async client or offload the synchronous fetch to a worker thread.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blocking fetch is a pre-existing property of the shared sync transform_video_create_request path (the image_url/image_urls branches already download the same way); making it non-blocking requires offloading the transform in llm_http_handler.async_video_generation_handler for all video providers, which is out of scope for this fix.

…-252)

nolgia-api mirrors the start frame into `input_reference` on every
image-to-video submission (a signed HTTPS URL). `map_openai_params` maps
`input_reference` -> `image`, and `transform_video_create_request` handed
that string straight to `_convert_image_to_gemini_format`, which calls
`.read()` on it:

    File "litellm/llms/gemini/videos/transformation.py", line 67
      image_bytes = image_file.read()
    AttributeError: 'str' object has no attribute 'read'

The request died inside the proxy before any upstream call, surfaced as
APIConnectionError -> HTTP 400, which the platform re-emitted to callers
as a generic 422. Every Veo 3.1 reference-image run was rejected this way
and fell back to Kling; the failure was misread as Veo refusing reference
images, when Google was never contacted at all.

The sibling `image_url` branch in this same transform already downloads a
URL and inlines it as base64, so an http(s) string image is now deferred
to it instead of the file-object encoder. A string that is not an http(s)
URL raises a clear ValueError rather than an opaque AttributeError,
mirroring the Vertex video config, which already guards this case. Dict
and file-like inputs are untouched.

Tests cover the production payload shape: `input_reference` as a signed
URL, the start frame mirrored into both `input_reference` and `image_url`
(downloaded once) alongside three `image_urls` reference images, and the
non-URL string error path. All mocked - no live provider calls.
@blackflame007
blackflame007 force-pushed the litellm_nol252_veo_i2v_url_image branch from ca668ea to 85e29e3 Compare August 1, 2026 20:26
Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
@blackflame007
blackflame007 merged commit 8f84815 into litellm_internal_staging Aug 1, 2026
75 checks passed
@blackflame007
blackflame007 deleted the litellm_nol252_veo_i2v_url_image branch August 1, 2026 20:44
blackflame007 added a commit that referenced this pull request Aug 3, 2026
Base moved four commits ahead (MiniMax video/image providers #31, the blind-catch
narrowing #33, and the two Gemini Veo route fixes #34/#35). Everything auto-merged
except tests/test_litellm/interactions/test_openapi_compliance.py, where both sides
had independently loosened the same Content-discriminator assertion after Google's
spec dropped the keyword: base via #31, upstream via BerriAI#35161. Kept upstream's
version, which is a strict superset (it accepts a discriminator mapping *or* a
per-variant `type` const/1-item enum, asserts the values are distinct, and pins
TextContent to "text") and matches the `_declared_type_value` helper already in
the file.

Budget ceilings: LIT002 (27511 -> 27678) and TRY004 (98 -> 100) were the only two
rules over limit on the merged tree with a count above the base, so the gates would
have failed. The merge adds no net-new violations: every file's LIT002 count in the
merged tree equals one of its two parents, so the overage is purely the union of
ceilings both sides had ratcheted down independently since the branch point. Raised
those two to the merged tree's actual counts; every other ratchet is untouched.

Also regenerated model_prices_and_context_window.schema.json, which the new upstream
sync check flagged as stale (missing `output_cost_per_audio`) already before this
merge.

Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant