From eea527e9b18489ebdaec17c017b4a3b1fd3dd9ac Mon Sep 17 00:00:00 2001 From: jibanez-staticduo Date: Sat, 12 Sep 2026 21:24:33 +0200 Subject: [PATCH] fix(images): support GPT Image 2.5 quality and preserve JSON edits --- litellm/images/main.py | 2 + .../litellm_core_utils/llm_cost_calc/utils.py | 26 +++++- ...odel_prices_and_context_window_backup.json | 16 ++-- litellm/types/images/main.py | 2 +- litellm/types/llms/openai.py | 2 + litellm/types/utils.py | 4 + litellm/utils.py | 1 + model_prices_and_context_window.json | 16 ++-- .../test_litellm/llms/chatgpt/test_images.py | 80 +++++++++++++++++++ .../test_gpt_image_cost_calculator.py | 39 ++++++++- 10 files changed, 168 insertions(+), 20 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index 11df9728ede..662903ee35e 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -869,6 +869,8 @@ def image_edit( non_default_params, extra_body if isinstance(extra_body, dict) else None, ) + if image_edit_provider_config.use_multipart_form_data() + else {**non_default_params, **(extra_body if isinstance(extra_body, dict) else {})} ) # Pre Call logging diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index e5977ca4156..341bb0235d9 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -1645,7 +1645,31 @@ def calculate_image_response_cost_from_usage( usage=normalized_usage, custom_llm_provider=custom_llm_provider, ) - return prompt_cost + completion_cost + cached_details: Final = ( + input_tokens_details.get("cached_tokens_details") + if isinstance(input_tokens_details, dict) + else getattr(input_tokens_details, "cached_tokens_details", None) + ) + if cached_details is None: + return prompt_cost + completion_cost + model_info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider) + cached_text: Final = _get_token_detail_value(cached_details, "text_tokens") or 0 + cached_image: Final = _get_token_detail_value(cached_details, "image_tokens") or 0 + input_text_tokens: Final = _get_token_detail_value(input_tokens_details, "text_tokens") or 0 + input_image_tokens: Final = _get_token_detail_value(input_tokens_details, "image_tokens") or 0 + if not (0 <= cached_text <= input_text_tokens and 0 <= cached_image <= input_image_tokens): + raise ValueError("Image cached token counts exceed their input modality counts") + text_rate: Final = model_info.get("input_cost_per_token") or 0.0 + image_rate: Final = model_info.get("input_cost_per_image_token") + cache_text_rate: Final = model_info.get("cache_read_input_token_cost") + cache_image_rate: Final = model_info.get("cache_read_input_image_token_cost") + text_savings: Final = cached_text * (text_rate - cache_text_rate) if cache_text_rate is not None else 0.0 + image_savings: Final = ( + cached_image * ((image_rate if image_rate is not None else text_rate) - cache_image_rate) + if cache_image_rate is not None + else 0.0 + ) + return prompt_cost + completion_cost - text_savings - image_savings def calculate_image_response_web_search_cost( diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 50d0ed52def..74f56f700c0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -29797,6 +29797,7 @@ }, "gpt-image-2.5-flare": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29807,11 +29808,11 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-flare" }, "gpt-image-2.5-flare-2026-09-08": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29822,11 +29823,11 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-flare" }, "gpt-image-2.5-sunburst": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29837,11 +29838,11 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst" }, "gpt-image-2.5-sunburst-2026-09-08": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29852,8 +29853,7 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst" }, "low/1024-x-1024/gpt-image-1.5": { "deprecation_date": "2026-12-01", diff --git a/litellm/types/images/main.py b/litellm/types/images/main.py index 5d80135a8a1..603b28e1081 100644 --- a/litellm/types/images/main.py +++ b/litellm/types/images/main.py @@ -16,7 +16,7 @@ class ImageEditOptionalRequestParams(TypedDict, total=False): input_fidelity: Literal["high", "low"] | None mask: str | None n: int | None - quality: Literal["high", "medium", "low", "standard", "auto"] | None + quality: Literal["high", "medium", "low", "standard", "auto", "xhigh", "max"] | None response_format: Literal["url", "b64_json"] | None size: str | None user: str | None diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 83eb3c4aa2a..47668823b47 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -2273,6 +2273,8 @@ class ImageGenerationRequestQuality(str, Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" + XHIGH = "xhigh" + MAX = "max" AUTO = "auto" STANDARD = "standard" HD = "hd" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 58b940227f8..70b7d603d3f 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -250,6 +250,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): cache_creation_input_token_cost_priority: float | None # OpenAI priority service tier pricing cache_creation_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing cache_read_input_token_cost: float | None + cache_read_input_image_token_cost: ReadOnly[float | None] cache_read_input_token_cost_flex: float | None # OpenAI flex service tier pricing cache_read_input_token_cost_priority: float | None # OpenAI priority service tier pricing cache_read_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing @@ -3804,6 +3805,9 @@ all_litellm_params = ( "enable_tag_filtering", "enable_json_schema_validation", "use_xai_oauth", + "chatgpt_auth_profile", + "chatgpt_token_dir", + "chatgpt_auth_file", "auto_router_config_path", "auto_router_config", "auto_router_default_model", diff --git a/litellm/utils.py b/litellm/utils.py index 7075d8776c3..6ba76281b44 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5897,6 +5897,7 @@ def _get_model_info_helper( input_cost_per_second=_model_info.get("input_cost_per_second", None), input_cost_per_audio_token=_model_info.get("input_cost_per_audio_token", None), input_cost_per_image_token=_model_info.get("input_cost_per_image_token", None), + cache_read_input_image_token_cost=_model_info.get("cache_read_input_image_token_cost", None), input_cost_per_video_token=_model_info.get("input_cost_per_video_token", None), input_cost_per_image=_model_info.get("input_cost_per_image", None), input_cost_per_audio_per_second=_model_info.get("input_cost_per_audio_per_second", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 50d0ed52def..74f56f700c0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -29797,6 +29797,7 @@ }, "gpt-image-2.5-flare": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29807,11 +29808,11 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-flare" }, "gpt-image-2.5-flare-2026-09-08": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29822,11 +29823,11 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-flare" }, "gpt-image-2.5-sunburst": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29837,11 +29838,11 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst" }, "gpt-image-2.5-sunburst-2026-09-08": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_image_token_cost": 2e-06, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "mode": "image_generation", @@ -29852,8 +29853,7 @@ "/v1/images/edits" ], "supports_vision": true, - "supports_pdf_input": true, - "source": "https://developers.openai.com/api/docs/pricing" + "source": "https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst" }, "low/1024-x-1024/gpt-image-1.5": { "deprecation_date": "2026-12-01", diff --git a/tests/test_litellm/llms/chatgpt/test_images.py b/tests/test_litellm/llms/chatgpt/test_images.py index 2c51b932e3e..7cd8d727a94 100644 --- a/tests/test_litellm/llms/chatgpt/test_images.py +++ b/tests/test_litellm/llms/chatgpt/test_images.py @@ -1,4 +1,6 @@ import base64 +import json +from typing import Final import httpx import pytest @@ -6,9 +8,87 @@ import pytest import litellm from litellm.llms.chatgpt.images import ChatGPTImageEditConfig, ChatGPTImageGenerationConfig from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.types.llms.openai import ImageGenerationRequestQuality from litellm.types.router import GenericLiteLLMParams +@pytest.mark.parametrize( + "model,quality", + [ + ("gpt-image-2", ImageGenerationRequestQuality.AUTO), + ("gpt-image-2.5-flare", ImageGenerationRequestQuality.XHIGH), + ("gpt-image-2.5-flare", ImageGenerationRequestQuality.MAX), + ("gpt-image-2.5-sunburst", ImageGenerationRequestQuality.XHIGH), + ("gpt-image-2.5-sunburst", ImageGenerationRequestQuality.MAX), + ], +) +@pytest.mark.parametrize("editing", [False, True]) +def test_image_25_transmits_model_quality_and_transparency(model, quality, editing, chatgpt_tokens): + expected: Final = { + "model": model, + "prompt": "a red circle with transparent surroundings", + "quality": quality.value, + "background": "transparent", + "size": "2048x2048", + **({"images": [{"image_url": "data:image/png;base64,aGVsbG8="}]} if editing else {}), + } + + def respond(request): + assert str(request.url) == "https://chatgpt.com/backend-api/codex/images/" + ( + "edits" if editing else "generations" + ) + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == expected + return httpx.Response( + 200, + json={"created": 1, "data": [{"b64_json": "aGVsbG8="}], "quality": quality.value}, + ) + + client: Final = HTTPHandler() + client.client = httpx.Client(transport=httpx.MockTransport(respond)) + operation: Final = litellm.image_edit if editing else litellm.image_generation + try: + response: Final = operation( + **{**expected, "model": "chatgpt/" + model, "quality": quality}, + client=client, + chatgpt_token_dir=chatgpt_tokens, + ) + assert response.data[0].b64_json == "aGVsbG8=" + assert response.quality == quality.value + finally: + client.client.close() + + +@pytest.mark.parametrize("model", ["gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst"]) +def test_json_edit_preserves_provider_params_and_extra_body_precedence(model, chatgpt_tokens): + references: Final = [{"image_url": "data:image/png;base64,aGVsbG8="}] + + def respond(request): + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == { + "model": model, + "prompt": "red circle", + "images": references, + "seed": 7, + "provider_options": {"steps": 30, "enabled": True}, + "output_compression": 90, + } + return httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aGVsbG8="}]}) + + with httpx.Client(transport=httpx.MockTransport(respond)) as http_client: + response: Final = litellm.image_edit( + model="chatgpt/" + model, + prompt="red circle", + images=references, + client=HTTPHandler(client=http_client), + chatgpt_token_dir=chatgpt_tokens, + seed=42, + output_compression=90, + extra_body={"seed": 7, "provider_options": {"steps": 30, "enabled": True}}, + ) + assert response.data[0].b64_json == "aGVsbG8=" + + @pytest.mark.parametrize("api_base", [None, "https://image-gateway.test"]) def test_generation_routes_with_chatgpt_oauth(chatgpt_tokens, api_base): requests = [] diff --git a/tests/test_litellm/test_gpt_image_cost_calculator.py b/tests/test_litellm/test_gpt_image_cost_calculator.py index 86a721f8743..36dcc921e22 100644 --- a/tests/test_litellm/test_gpt_image_cost_calculator.py +++ b/tests/test_litellm/test_gpt_image_cost_calculator.py @@ -10,15 +10,15 @@ gpt-image-1 uses token-based pricing: - Image Output: $40.00/1M tokens """ - +from typing import Final import pytest import litellm from litellm.types.utils import ( CompletionTokensDetailsWrapper, - ImageResponse, ImageObject, + ImageResponse, ImageUsage, ImageUsageInputTokensDetails, PromptTokensDetailsWrapper, @@ -42,6 +42,41 @@ def _use_local_model_cost_map(monkeypatch): class TestGPTImageCostCalculator: """Test the OpenAI gpt-image cost calculator""" + @pytest.mark.parametrize("family", ["flare", "sunburst"]) + @pytest.mark.parametrize("snapshot", ["", "-2026-09-08"]) + @pytest.mark.parametrize("call_type", ["image_generation", "image_edit"]) + @pytest.mark.parametrize("cached_text,cached_image", [(0, 0), (50, 500)]) + def test_image_25_official_prices(self, family, snapshot, call_type, cached_text, cached_image): + response: Final = ImageResponse( + created=1, + data=[], + usage={ + "input_tokens": 1100, + "output_tokens": 100, + "total_tokens": 1200, + "input_tokens_details": { + "text_tokens": 100, + "image_tokens": 1000, + "cached_tokens": cached_text + cached_image, + "cached_tokens_details": {"text_tokens": cached_text, "image_tokens": cached_image}, + }, + }, + ) + cost: Final = litellm.completion_cost( + model="gpt-image-2.5-" + family + snapshot, + completion_response=response, + call_type=call_type, + custom_llm_provider="openai", + ) + expected: Final = ( + (100 - cached_text) * 5e-6 + + cached_text * 1.25e-6 + + (1000 - cached_image) * 8e-6 + + cached_image * 2e-6 + + 100 * 30e-6 + ) + assert cost == pytest.approx(expected) + def test_gpt_image_1_cost_with_text_only(self): """Test cost calculation with only text input tokens""" from litellm.llms.openai.image_generation.cost_calculator import cost_calculator