🐛 fix(image_gen): route nano-banana text-to-image through /v1/images/generations (4K) - #63
Conversation
…generations
gemini-3-pro-image silently returned ~1K (1408x768) regardless of the
configured resolution because the chat/completions path has NO
imageConfig->generationConfig mapping (verified against LiteLLM 1.91.2
and 1.92.0 source). LiteLLM maps resolution ONLY on /v1/images/generations.
Route text-to-image to POST {base_url}/v1/images/generations with a NESTED
imageConfig ({"imageSize": resolution, "aspectRatio": ratio}), which maps to
Vertex generationConfig.imageConfig for pro/flash/lite (model-agnostic, no
flag), so Pro (and all image models) honor 4K. Parse the images-API response
(b64_json preferred, url fallback) and save it.
Keep the EDIT / reference path on chat/completions: the images endpoint has
no clean input-image contract at 1.92.0 (image input lives on the multipart
/v1/images/edits route, unverified against this proxy), the chat edit path
works today, and resolution matters less for edits since the model preserves
source dimensions. The split is documented in the module docstring + comments.
Preserve resolution-fallback resilience: if an older proxy rejects the nested
imageConfig, retry once without it so generation still lands. Reuse
_resolve_model/_resolve_resolution/_clamp_resolution and the aspect map
unchanged; the Lite 1K cap still clamps on the images path.
Tests mock the HTTP layer (no running proxy): assert text-to-image hits
/v1/images/generations with the nested imageConfig carrying imageSize +
aspectRatio, response parse (b64_json/url), imageConfig-rejection fallback,
and that the edit/reference path still routes to chat/completions unchanged.
cwest
left a comment
There was a problem hiding this comment.
Reviewed from the remote at 1d40f3a. The split is the right call and the diff does exactly what the description says.
Text-to-image now POSTs to {base_url}/images/generations (base_url ends in /v1, so it lands on /v1/images/generations) with a nested imageConfig {imageSize, aspectRatio} and no flat imageSize/image_size at the top level. The response parser reads data[0].b64_json first and falls back to data[0].url, saving via save_b64_image / save_url_image. The edit/reference path is untouched on /chat/completions and still carries its flat image_config, not the nested one. The imageConfig-rejection fallback retries once without the block, and _is_resolution_field_error was widened to catch imageConfig/imageSize spellings while still letting an unrelated 400 surface as a real error. _resolve_model, _resolve_resolution, _clamp_resolution and the aspect map are reused unchanged, and the Lite 1K cap still clamps on the images path. save_url_image moved to a top-level import and is used on both paths. It stays inside the plugin: no core tool-schema change, no per-call param, no new env var.
The tests earn their keep. test_posts_to_images_generations_endpoint pins the URL and test_payload_shape_text_to_image_nested_image_config asserts the nested imageConfig with imageSize == configured resolution and aspectRatio, plus that no flat resolution keys and no chat-only keys leak — that pair is what would have caught the original ~1K bug. Response parse is covered for b64_json, url, and b64_json-preferred-when-both. The fallback test asserts the two-call sequence (first with imageConfig, retry without). The edit path has both a routing test (still hits chat/completions) and a payload-shape test (keeps image_config, does not carry imageConfig). These are behavior contracts on request shape and outcome, not snapshot/change-detector tests.
I ran the suites at this SHA in a throwaway checkout rather than trusting the counts: tests/plugins/image_gen/test_nano_banana_provider.py -> 53 passed; the twelve load-bearing contract tests pass by name; tests/tools/test_image_generation.py + test_image_generation_image_to_image.py -> 81 passed; tests/plugins/ -> 1696 passed, no failures, no new regressions against the cwest/integration baseline. The commit is signed (good signature), Conventional Commits + emoji, no attribution trailers.
Live 4K pixel proof is not exercised here — it needs the proxy on LiteLLM >=1.92.0 (proxy PR #10). Correctness is established from source and mocked tests; the pixel confirmation is the post-restart step.
No changes needed.
…generations (#63) gemini-3-pro-image silently returned ~1K (1408x768) regardless of the configured resolution because the chat/completions path has NO imageConfig->generationConfig mapping (verified against LiteLLM 1.91.2 and 1.92.0 source). LiteLLM maps resolution ONLY on /v1/images/generations. Route text-to-image to POST {base_url}/v1/images/generations with a NESTED imageConfig ({"imageSize": resolution, "aspectRatio": ratio}), which maps to Vertex generationConfig.imageConfig for pro/flash/lite (model-agnostic, no flag), so Pro (and all image models) honor 4K. Parse the images-API response (b64_json preferred, url fallback) and save it. Keep the EDIT / reference path on chat/completions: the images endpoint has no clean input-image contract at 1.92.0 (image input lives on the multipart /v1/images/edits route, unverified against this proxy), the chat edit path works today, and resolution matters less for edits since the model preserves source dimensions. The split is documented in the module docstring + comments. Preserve resolution-fallback resilience: if an older proxy rejects the nested imageConfig, retry once without it so generation still lands. Reuse _resolve_model/_resolve_resolution/_clamp_resolution and the aspect map unchanged; the Lite 1K cap still clamps on the images path. Tests mock the HTTP layer (no running proxy): assert text-to-image hits /v1/images/generations with the nested imageConfig carrying imageSize + aspectRatio, response parse (b64_json/url), imageConfig-rejection fallback, and that the edit/reference path still routes to chat/completions unchanged. (cherry picked from commit 24a741c)
Why
gemini-3-pro-imagesilently returned ~1K (1408x768) regardless of theconfigured resolution. Root cause (verified against LiteLLM 1.91.2 and
1.92.0 source): the
/chat/completionspath has noimageConfig→generationConfigmapping, soimage_sizeon chat is inert for Pro. LiteLLMmaps resolution only on
/v1/images/generations.What
Route text-to-image generation through
/v1/images/generationswith anested
imageConfig, so Pro (and all image models) honor 4K.Exact text-to-image request shape implemented
imageConfig— maps to VertexgenerationConfig.imageConfigforpro/flash/lite (model-agnostic, no flag). Flat
imageSize/image_sizeat thetop level are dropped by the proxy, so they are not sent.
data[0].b64_json(preferred, decoded viasave_b64_image) and/ordata[0].url(fetched viasave_url_image).base_urlalready ends in/v1, so the code joins{base_url}/images/generations→
{host}/v1/images/generations.Edit / reference path — kept on
/chat/completions(unchanged)Design decision per spec change #2: the images endpoint has no clean
input-image contract at LiteLLM 1.92.0 (image input lives on the multipart
/v1/images/editsroute, unverified against this proxy). The chat edit pathworks today, and resolution matters less for edits because the model preserves
the source image's dimensions regardless. So text-to-image — where 4K actually
matters — moves to the images path, and edit/reference stays on chat. The
split is documented in the module docstring and inline comments.
Resilience
If an older proxy rejects the nested
imageConfig(pre-1.92.0), the request isretried once without it so generation still lands (falls back to default
geometry rather than hard-failing). Reuses
_resolve_model/_resolve_resolution/_clamp_resolutionand the aspect map unchanged; theLite 1K cap still clamps on the images path.
Tests (mocks only — no running proxy)
New/updated behavior-contract tests assert:
/v1/images/generationswith a nestedimageConfigcarrying
imageSize(= configured resolution) andaspectRatio(thiscontract would have caught the original ~1K bug);
b64_json→ saved path;url→ saved path;b64_jsonpreferred when both present;
imageConfig-rejection fallback → retry without it;/chat/completionswith the chat payloadshape (and does NOT carry the nested
imageConfig);Wider regression scope (no regressions):
Live verification — DEFERRED to proxy restart (not done here)
Live 4K pixel proof requires the proxy on LiteLLM >=1.92.0 (proxy PR #10).
Until the proxy restarts onto 1.92.0, live 4K cannot be verified — that is
Casey's restart step. This PR does not restart the proxy or gateway.
Post-restart verification command (expect ~4K, not ~1376x768):
Concretely: set
image_gen.provider: nano-banana+image_gen.nano-banana.resolution: 4K,run an
image_generateat aspectlandscape, and confirm the decoded PNG is~4K (5504×3072) rather than the old ~1K (1376×768).