fix(cost): bill Responses API tool usage from usage.tool_usage and the image tool model

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-22 01:49:35 +00:00
parent bb661f8305
commit c5ee5432dd
3 changed files with 232 additions and 13 deletions

View file

@ -47,15 +47,24 @@ def _output_item_type(output_item: object) -> str | None:
return item_type if isinstance(item_type, str) else None
def _reported_web_search_requests(response_object: ResponsesAPIResponse) -> int | None:
tool_usage: Final = getattr(response_object, "tool_usage", None)
if tool_usage is None:
def _responses_tool_usage(response_object: ResponsesAPIResponse) -> ResponsesToolUsage | None:
top_level: Final = getattr(response_object, "tool_usage", None)
raw: Final = (
top_level if top_level is not None else getattr(getattr(response_object, "usage", None), "tool_usage", None)
)
if raw is None:
return None
try:
web_search: Final = ResponsesToolUsage.model_validate(tool_usage).web_search
return ResponsesToolUsage.model_validate(raw)
except ValidationError:
return None
return None if web_search is None else web_search.num_requests
def _reported_web_search_requests(response_object: ResponsesAPIResponse) -> int | None:
tool_usage: Final = _responses_tool_usage(response_object)
if tool_usage is None:
return None
return None if tool_usage.web_search is None else tool_usage.web_search.num_requests
def _usage_reports_server_side_web_search_calls(usage: Usage) -> bool:
@ -240,7 +249,19 @@ class StandardBuiltInToolCostTracking:
return max(count, 1)
@staticmethod
def _image_generation_call_cost(output_item: object, custom_llm_provider: str | None) -> float:
def _image_generation_tool_model(response_object: ResponsesAPIResponse) -> str:
tools: Final[list[object]] = cast(
list[object], getattr(response_object, "tools", None) or []
) # cast-ok: tools entries may be dicts or pydantic objects
for tool in tools:
if _output_item_field(tool, "type") != "image_generation":
continue
if isinstance(model := _output_item_field(tool, "model"), str) and model:
return model
return "gpt-image-1"
@staticmethod
def _image_generation_call_cost(output_item: object, tool_model: str, custom_llm_provider: str | None) -> float:
from litellm.cost_calculator import (
default_image_cost_calculator, # pyright: ignore[reportUnknownVariableType] # optional_params param is untyped
)
@ -252,7 +273,7 @@ class StandardBuiltInToolCostTracking:
size: Final = _output_item_field(output_item, "size")
try:
return default_image_cost_calculator(
model="gpt-image-1",
model=tool_model,
custom_llm_provider=custom_llm_provider or "openai",
quality=quality if isinstance(quality, str) and quality != "auto" else None,
n=1,
@ -262,15 +283,53 @@ class StandardBuiltInToolCostTracking:
verbose_logger.debug("Could not price Responses API image_generation_call item: %s", e)
return 0.0
@staticmethod
def _image_generation_token_cost(
response_object: ResponsesAPIResponse, tool_model: str, custom_llm_provider: str | None
) -> float | None:
tool_usage: Final = _responses_tool_usage(response_object)
if tool_usage is None or tool_usage.image_gen is None or tool_usage.image_gen.total_tokens <= 0:
return None
try:
model_info: Final = litellm.get_model_info(
model=tool_model, custom_llm_provider=custom_llm_provider or "openai"
)
except Exception as e:
verbose_logger.debug("Could not resolve pricing for image tool model %s: %s", tool_model, e)
return 0.0
image_gen: Final = tool_usage.image_gen
input_details: Final = image_gen.input_tokens_details
output_details: Final = image_gen.output_tokens_details
return (
(input_details.text_tokens if input_details else 0) * (model_info.get("input_cost_per_token") or 0)
+ (input_details.image_tokens if input_details else 0) * (model_info.get("input_cost_per_image_token") or 0)
+ (output_details.image_tokens if output_details else 0)
* (model_info.get("output_cost_per_image_token") or 0)
+ (output_details.text_tokens if output_details else 0) * (model_info.get("output_cost_per_token") or 0)
)
@staticmethod
def _handle_image_generation_cost(response_object: object, custom_llm_provider: str | None) -> float:
if not isinstance(response_object, ResponsesAPIResponse):
return 0.0
output: Final[list[object]] = cast(list[object], response_object.output) # cast-ok: narrowed by isinstance
return sum(
StandardBuiltInToolCostTracking._image_generation_call_cost(output_item, custom_llm_provider)
completed_items: Final = tuple(
output_item
for output_item in output
if _output_item_type(output_item) == "image_generation_call"
and _output_item_field(output_item, "status") == "completed"
)
if not completed_items:
return 0.0
tool_model: Final = StandardBuiltInToolCostTracking._image_generation_tool_model(response_object)
token_cost: Final = StandardBuiltInToolCostTracking._image_generation_token_cost(
response_object, tool_model, custom_llm_provider
)
if token_cost is not None:
return token_cost
return sum(
StandardBuiltInToolCostTracking._image_generation_call_cost(output_item, tool_model, custom_llm_provider)
for output_item in completed_items
)
@staticmethod
@ -508,9 +567,12 @@ class StandardBuiltInToolCostTracking:
return False
elif isinstance(response_object, ResponsesAPIResponse):
# response api explicitly includes web_search_call in the output
return StandardBuiltInToolCostTracking.response_includes_output_type(
if StandardBuiltInToolCostTracking.response_includes_output_type(
response_object=response_object, output_type="web_search_call"
)
):
return True
reported: Final = _reported_web_search_requests(response_object)
return isinstance(reported, int) and reported > 0
elif usage is not None:
if get_web_search_requests_from_usage(usage) is not None or (
hasattr(usage, "prompt_tokens_details")

View file

@ -1332,10 +1332,28 @@ class WebSearchToolUsage(BaseModel):
num_requests: NonNegativeInt
class ImageGenTokenDetails(BaseModel):
model_config = ConfigDict(frozen=True)
image_tokens: NonNegativeInt = 0
text_tokens: NonNegativeInt = 0
class ImageGenToolUsage(BaseModel):
model_config = ConfigDict(frozen=True)
input_tokens: NonNegativeInt = 0
output_tokens: NonNegativeInt = 0
total_tokens: NonNegativeInt = 0
input_tokens_details: ImageGenTokenDetails | None = None
output_tokens_details: ImageGenTokenDetails | None = None
class ResponsesToolUsage(BaseModel):
model_config = ConfigDict(frozen=True)
web_search: WebSearchToolUsage | None = None
image_gen: ImageGenToolUsage | None = None
ResponsesAPIStatus = Literal["completed", "failed", "in_progress", "cancelled", "queued", "incomplete"]

View file

@ -697,7 +697,7 @@ _BEDROCK_MANTLE_WEB_SEARCH_RATE = 0.012
def _openai_responses_response(model, output):
def _openai_responses_response(model, output, usage=None, tools=None):
return ResponsesAPIResponse.model_validate(
{
"id": "resp_1",
@ -706,7 +706,8 @@ def _openai_responses_response(model, output):
"object": "response",
"status": "completed",
"output": output,
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
"usage": usage or {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
**({"tools": tools} if tools is not None else {}),
}
)
@ -851,3 +852,141 @@ def test_completion_cost_includes_responses_image_generation_tool_cost(local_mod
assert cost_with_image - cost_without_image == pytest.approx(
litellm.model_cost[_GPT_IMAGE_1_HIGH_1024_COST_KEY]["input_cost_per_image"]
)
def test_responses_usage_tool_usage_web_search_billed_without_output_item(local_model_cost_map):
"""usage.tool_usage.web_search.num_requests bills web search even when no web_search_call item is present."""
model = "gpt-5.4-mini"
per_call = litellm.get_model_info(model)["search_context_cost_per_query"]["search_context_size_medium"]
for num_requests in (1, 2):
response = _openai_responses_response(
model,
[dict(_ASSISTANT_MESSAGE_OUTPUT_ITEM)],
usage={
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
"tool_usage": {"web_search": {"num_requests": num_requests}},
},
)
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model=model,
response_object=response,
usage=None,
custom_llm_provider="openai",
standard_built_in_tools_params=None,
)
assert cost == pytest.approx(num_requests * per_call)
def _image_gen_token_usage(input_text, input_image, output_image, output_text):
return {
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 15,
"tool_usage": {
"image_gen": {
"input_tokens": input_text + input_image,
"output_tokens": output_image + output_text,
"total_tokens": input_text + input_image + output_image + output_text,
"input_tokens_details": {"image_tokens": input_image, "text_tokens": input_text},
"output_tokens_details": {"image_tokens": output_image, "text_tokens": output_text},
}
},
}
def _expected_image_gen_token_cost(model_info, input_text, input_image, output_image, output_text):
return (
input_text * (model_info.get("input_cost_per_token") or 0)
+ input_image * (model_info.get("input_cost_per_image_token") or 0)
+ output_image * (model_info.get("output_cost_per_image_token") or 0)
+ output_text * (model_info.get("output_cost_per_token") or 0)
)
def test_responses_image_tool_model_from_tools_bills_token_usage(local_model_cost_map):
"""The image tool's tools[].model is used and usage.tool_usage.image_gen tokens bill at that model's rates."""
tool_model = "gpt-image-2"
model_info = litellm.get_model_info(tool_model, custom_llm_provider="openai")
tools = [{"type": "image_generation", "model": tool_model, "quality": "low", "size": "1024x1024"}]
output = [
{
"type": "image_generation_call",
"id": "ig_1",
"status": "completed",
"quality": "low",
"size": "1024x1024",
"result": "AAAA",
},
dict(_ASSISTANT_MESSAGE_OUTPUT_ITEM),
]
response = _openai_responses_response("gpt-5", output, usage=_image_gen_token_usage(10, 0, 50, 0), tools=tools)
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model="gpt-5",
response_object=response,
usage=None,
custom_llm_provider="openai",
standard_built_in_tools_params=None,
)
assert cost > 0
assert cost == pytest.approx(_expected_image_gen_token_cost(model_info, 10, 0, 50, 0))
response_more_tokens = _openai_responses_response(
"gpt-5", output, usage=_image_gen_token_usage(10, 0, 80, 0), tools=tools
)
cost_more = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model="gpt-5",
response_object=response_more_tokens,
usage=None,
custom_llm_provider="openai",
standard_built_in_tools_params=None,
)
assert cost_more != cost
assert cost_more == pytest.approx(_expected_image_gen_token_cost(model_info, 10, 0, 80, 0))
def test_responses_zero_image_gen_tokens_fall_back_to_per_image_pricing(local_model_cost_map):
"""An all-zero image_gen usage block keeps the per-image path for the tool's model/quality/size."""
tool_model = "gpt-image-1"
quality = "low"
size = "1024x1024"
tools = [{"type": "image_generation", "model": tool_model, "quality": quality, "size": size}]
response = _openai_responses_response(
"gpt-5",
[
{
"type": "image_generation_call",
"id": "ig_1",
"status": "completed",
"quality": quality,
"size": size,
"result": "AAAA",
}
],
usage=_image_gen_token_usage(0, 0, 0, 0),
tools=tools,
)
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model="gpt-5",
response_object=response,
usage=None,
custom_llm_provider="openai",
standard_built_in_tools_params=None,
)
from litellm.cost_calculator import default_image_cost_calculator
assert cost == pytest.approx(
default_image_cost_calculator(
model=tool_model,
custom_llm_provider="openai",
quality=quality,
n=1,
size=size,
)
)
assert cost > 0