Repository navigation
Conversation
Merging this PR will not alter performance1 warning
Comparing |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…n state, sharing OpenRouter's state builder
…y as the images field
[Medium impact] The changes affect image-bearing requests in a bounded set of decision-model workflows. Explicit review of the retry’s rate-limit and timeout costs is required before merging Findings
SummaryAdds image support for Perplexity and supported OpenRouter decision models through
Reviews (11) · Last reviewed commit: "Merge branch 'openrouter-decisions-image..." · Reviewed by Greptile |
…aller state items, normalize URL-safe base64 OpenRouter decision models now take the image types, count and size limits of the provider behind them (TypeSafe or Cloudflare), replacing the TypeSafe-only media type override with one rules lookup. Perplexity caps images at 8 before the call. A JSON object state stays one object item and a list state keeps its items ahead of the images, and URL-safe base64 in data URLs is converted to standard base64, which every upstream requires.
…map OpenRouter perplexity models, normalize state images only Images past a count or size limit now drop under drop_params, keeping the longest in-order run that fits, and answer 400 without it. Cloudflare clef follows the same rule. OpenRouter perplexity/ models take Perplexity's 8-image limit. Inside state, URL-safe base64 becomes standard base64 with padding and image/jpg goes out as image/jpeg, while the images field keeps the caller's base64 as on main.
…em, answer Jev 1.14 box questions OpenRouter decision models other than the TypeSafe, Cloudflare and Perplexity upstreams are text-only, so images to them go back to main's behavior: a 400 before the call without drop_params, dropped under drop_params. Add an openrouter/typesafe/jev-1.14 capability row so box questions reach Jev 1.14 through OpenRouter, and log the body that was actually sent when an image drops after remote images were inlined.
…filek/litellm into openrouter-decisions-images # Conflicts: # tests/unit/decisions/test_main.py
This reverts commit ac0da7e. OpenRouter returns usage.cost on every call, and a copied price would go stale.
…mages when the provider refuses them, and keep a later image that still fits the total
…Router, cap downloads at the image count, and keep empty requests refused
…y state loses its images
…s dropped, on /v1/decisions and blank state
…s-images # Conflicts: # litellm/llms/typesafe/decisions/transformation.py
| supports_images = True | ||
| max_images = 8 | ||
| image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"}) | ||
| inlines_remote_images = True |
There was a problem hiding this comment.
Low: Image download limit is enforced after buffering
Enabling remote inlining here and in OpenRouterDecisionsConfig lets an authenticated caller submit a public image URL serving an oversized body and exhaust proxy-worker memory. Both download helpers use non-streaming HTTP handlers, so _process_image_response() checks MAX_IMAGE_URL_DOWNLOAD_SIZE_MB only after HTTPX buffers the entire response—enforce the cap during retrieval using max_response_bytes for the async handler and bounded streaming for sync downloads.
PR overviewThe PR adds support for sending images in the state array to Perplexity and OpenRouter decision models, including inlining images from remote URLs. One issue remains open, with none addressed so far. An authenticated caller can provide a public image URL with an oversized response that is fully buffered before the download-size limit is checked, potentially exhausting proxy-worker memory. Enforcing the limit during retrieval would address this resource-exhaustion risk. Open issues (1)
Fixed/addressed: 0 · PR risk: 6/10 |
…ge to the declared type check, and count null as no text
…ad is a type the model refuses
…filek/litellm into openrouter-decisions-images # Conflicts: # tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py
… so its byte drops keep main's error
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
…age keeps a typed state's request
…s-images # Conflicts: # litellm/decisions/call.py # litellm/llms/base_llm/decisions/transformation.py
…eaves no input
A `type` value now counts as text only when its object has no `text` key, so a blank
text part with every image dropped answers 400 again while {"type": "refund"} keeps
main's 200. After remote images past the count are dropped, a downloaded image dropped
for its type no longer keeps the lenient no-input check, so a blank request keeps
main's 400 instead of a billed 200 with nothing in it.
…filek/litellm into openrouter-decisions-images
| retry_body: Final = _request_body(retry) | ||
| return await _aposted(retry, retry_body, retry_body) |
There was a problem hiding this comment.
Retries reduce successful requests
Near provider limits, image retries reduce successes and can double timeout. Repository rules require explicit review of these costs before merging
Rule Used: What: do not treat the caveats mentioned in the PR description as acceptable tradeoffs that some human has read and signed off on. Why: almost every time, the PR description is AI-generated, and in fact, many times, the PR description is very long. ... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…s-images # Conflicts: # litellm/llms/base_llm/decisions/transformation.py # tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py
…s-images # Conflicts: # litellm/litellm_core_utils/prompt_templates/image_handling.py
Perplexity and OpenRouter decision models read images, but LiteLLM answered 400 to any image, or dropped it under
drop_params. This PR sends them asimage_urlitems insidestateon/v1/decisionsand/v1/systemoneBreaking change: Perplexity and OpenRouter decision callers with
drop_paramsnow send images that main dropped. Before upgrading, check rate limit room for a second send, and that nothing needs a router fallback for a dropped image/stupid: What the PR adds
/stupid: What breaks
Original
TLDR
Problem this solves:
drop_paramsHow it solves it:
image_urlitems insidestate, inlined from remote URLs and in standard base64drop_params, else answer 400drop_paramson Perplexity and OpenRouter, a request whose images are refused or time out is sent again without themUser Flow
Before: a developer asks pplx-decider about a product photo
perplexity/pplx-decider-v1.2with text and aninput_imagedata URLperplexity does not support parameters: ['input_image'], or ignores the image underdrop_paramsAfter: the same request gets answers about the photo
/v1/systemoneand OpenRouter Jev and Clef flashdrop_paramsDecisions
Choices made and the alternatives
stateas{"type": "image_url", "image_url": {"url": "data:..."}}itemsimagesfield and name this shape{"type": "text"}parts also work live, plain strings keep the body smallerstatesent with images stays one object item ahead of the imagesstate, so the model still reads the caller's fieldsstatethat is already a list keeps its items, followed by the image itemsimagesfielddata:IMAGE/PNG;name=x.png;base64,...passes our type check, so it goes out asdata:image/png;base64,...state, URL-safe base64 (-and_) is converted to standard base64, line breaks are removed, missing=padding is added, andimage/jpggoes out asimage/jpegimagesfield that Cloudflare, vLLM and LM Studio get keeps the caller's base64 as on main, since nothing here proves those providers want it changeddrop_params, per the usual conventiontypesafe/...,~typesafe/...) take only PNG and JPEG, as TypeSafe does nativelyperplexity/...) take at most 8 images, as pplx-decider does nativelydrop_params, as on maindrop_paramsinto an upstream 400drop_paramsand answer 400 without it, the usual conventiondrop_params, more images than the count limit answer 400 before any downloaddrop_params, remote URLs past the count are dropped before any download, so a long list costs at most the count in fetches, as main fetched none past itdrop_paramswas the alternative, which turns a 200 into a 400 for callers whose images main droppeddrop_params(feat(decisions)!: send images to Cloudflare Clef through its images field #45734). It now follows the convention, so underdrop_paramsit answers 200 with the images that fit, and without it still answers 400 before any downloaddrop_params, and answers 400 without it or when the request has nothing left to decide ondrop_paramsinto a 400drop_params, and answer 400 when nothing is left to decide onstateorinput_text) with every image dropped and no audio or video clips counts as nothing left, since those drops were errors on main. An image dropped for its declared type, downloaded or not, keeps main's answer, so that request still reaches the model. A downloaded image dropped for its type after remote images past the count were dropped does not, since main refused that request for the countstatenested pastDEFAULT_MAX_RECURSE_DEPTHlevels counts as no text, so the check never walks past the stack. Counting it as text was the alternative, which answered garbage bytes with a billed 200 from no input where main answered an errorstatecounts as text, sincestateis the caller's own JSON, except atypevalue in an object that also has atextkey, where the part's text is intext. So{"type": "refund"}keeps main's 200 and[{"type": "text", "text": ""}]keeps main's error. Counting everytypewas the alternative, which answered a blank text part with a billed 200 from no input, and skipping everytypewas the other, which turned main's 200 on{"type": "refund"}into a 400state, which main sent them with no imagesdrop_paramsthey still go upstream and get the provider's 400, as on maindrop_paramsinto a 400drop_paramson Perplexity and OpenRouter, when the provider answers 400, 408, 413 or 422 to a request that carried images, or the request times out, LiteLLM sends it once more without the images, which is the request main sentimage decode faileddrop_paramsreaches the caller after one calldrop_paramsbehind a setting that is off by defaultstatestays an object thereopenrouter/typesafe/jev-1.14andopenrouter/~typesafe/jev-latest, withsupports_box_questions: trueusage.costOpenRouter returns. A live call reported $2.79e-05 with the rows and $2.77e-05 withoutjev-1.14.0) differ from OpenRouter's andtypesafe/jev-latestlacks the flagtypesafe/jev-1.13gets no row, since it rejects box questionsPre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or moreDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Both legs run
litellm --config config.yaml --port $PORT --num_workers 2with no database and real provider keys. Before is the merge base 4ce724e, and e8f572b, c4b01b2 or f4a2e2b for the rerunsLast updated: 9c005c9. QA ran on 1c82abd. Changes since QA: no-text checks and merges of main. Proof remains valid because requests with text or a kept image take the same path. Verification: decisions unit tests, and the headline and changed cases live at 9c005c9, all matching
Proxy config and request variables
LITELLM_LOCAL_MODEL_COST_MAP=Trueis set on both legs.KEYis the master key.red.pngis a 400x300 white PNG with one red square, and the other variables come from itred.webpandred.jpgarered.pngsaved as WebP and JPEG,$Uis the same PNG in URL-safe base64,$Vis$Uwithout its=padding,$Pis one quoted PNG data URL,big.pngis a 5 MB noise PNG,$Gis a 1x1 GIF,$URLis a dashboard screenshot with an error popup,$MISSINGanswers 404,$WRAPPEDis the PNG's base64 in 76-character lines joined by JSON\nescapes,$NOTIMGis base64 bytes that are no image,$CUTis the first half ofred.png,mid.pngis a 3 MB noise PNG, and$LOGFis/tmp/decision-images-<worktree>.log, where the logging callback writes the images it was givenPerplexity /v1/decisions with input_image
Before (4ce724e)
After (1c82abd)
73 more rows
The other 74 cases' commands and full output on both legs are in this gist
QA observations
Type
🆕 New Feature
Caveats (if any)
Severe
drop_paramson Perplexity and OpenRouter, a refused image is sent again without it, so near the rate limit fewer requests answer 200, 92 of 120 on main and 48 here. Without the second send, main's 200 becomes a 400. Options are in DecisionsLow
usage.cost, or $0 without it, and has no image count limit. Its two new rows turn/healthfrom 503 to 200, add two/v1/modelsids, and let box questions reach Jevimage_rules_forreturns a provider config for four limitsdrop_params, native Cloudflare still answers 422 to URL-safe base64, as on maintimeout, and repeats a 400 or 422 the image did not cause. LiteLLM limits count one requestlog_pre_api_calltwice, the first with the refused image, and its spend row prices only the second senddrop_paramsdroptimeout(100 s, default 6000 s), eight 30 MiB URLs used 1.6 GB, and a failed download is not replacedstatenested pastDEFAULT_MAX_RECURSE_DEPTHcounts as no text, so lowering that setting refuses more of what main refusedFinal Attestation