fix(images): support GPT Image 2.5 quality and preserve JSON edits

This commit is contained in:
jibanez-staticduo 2026-09-12 21:24:33 +02:00
parent afa0b23dc2
commit eea527e9b1
No known key found for this signature in database
10 changed files with 168 additions and 20 deletions

View file

@ -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

View file

@ -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(

View file

@ -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",

View file

@ -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

View file

@ -2273,6 +2273,8 @@ class ImageGenerationRequestQuality(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
XHIGH = "xhigh"
MAX = "max"
AUTO = "auto"
STANDARD = "standard"
HD = "hd"

View file

@ -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",

View file

@ -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),

View file

@ -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",

View file

@ -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 = []

View file

@ -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