feat: update EmpirioLabs models in model_prices_and_context_window.json (#49980)

* feat: update EmpirioLabs models in model_prices_and_context_window.json

* chore(endpoints): regenerate provider endpoint support for the EmpirioLabs cost map rows

* fix(empiriolabs): drop the retired max_tokens field, route embeddings and remove the unrouted rerank row

The cost map test rejects any row carrying max_tokens, so every EmpirioLabs row keeps max_input_tokens and max_output_tokens only. The provider JSON now lists /v1/embeddings so embedding(model="empiriolabs/text-embedding-v4") reaches the OpenAI-compatible embeddings route instead of failing provider lookup. The qwen3-rerank row goes away because no rerank route exists for the provider. Endpoint support regenerated from the cost map

* fix(empiriolabs): mark audio input and image embedding input on the rows the provider lists as multimodal

The three vision embedding rows get supports_embedding_image_input and the eleven chat rows whose input_modalities on GET https://api.empiriolabs.ai/v1/models include audio get supports_audio_input, so capability checks stop treating them as text only

* fix(empiriolabs): carry the long-context price tiers on the 24 rows the provider bills in bands

EmpirioLabs bills 24 of these models at a higher rate once the prompt passes 32K, 128K, 256K, 272K or 512K tokens, and the rows carried only the first band, so a 300K-token qwen3-7-plus request was priced at a third of the real cost. Each of those rows now carries input_cost_per_token_above_<N>k_tokens, output_cost_per_token_above_<N>k_tokens and, where the row already has cache_read_input_token_cost, cache_read_input_token_cost_above_<N>k_tokens for every band, taken from pricing[].min_context on GET https://api.empiriolabs.ai/v1/models for the base rows and from the Variants rate card on docs.empiriolabs.ai/models/<id> for the six variant rows

* feat(empiriolabs): bill the per-call web search fee reported in usage.tool_usage

EmpirioLabs charges a flat fee per invoked web_search, web_search_image or image_search call and reports the counts in usage.tool_usage on the chat response. The JSON provider config now carries that count as usage.server_tool_use.web_search_requests when providers.json sets special_handling.web_search_requests_from_tool_usage, the per-call web search cost path bills the usage-reported count instead of flooring to one call, and the 39 EmpirioLabs rows the provider lists a per-call search price for carry it as search_context_cost_per_query, from unit_pricing on GET /v1/models for base rows and the Variants rate card on docs.empiriolabs.ai for variant rows

* fix(openai_like): mark the dict signature of transform_parsed_response_dict as matching BaseConfig for LIT001

* fix(openai_like): mark both dict annotations of transform_parsed_response_dict for LIT001

---------

Co-authored-by: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-11-04 12:11:52 -08:00 • committed by GitHub
parent e9e745797c
commit e69da69891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 3379 additions and 19 deletions

View file

@ -224,7 +224,20 @@ class StandardBuiltInToolCostTracking:
web_search_options=standard_built_in_tools_params.get("web_search_options", None),
model_info=model_info,
)
return per_call_cost * StandardBuiltInToolCostTracking._count_web_search_calls(response_object)
return per_call_cost * StandardBuiltInToolCostTracking._billable_web_search_calls(
resolved_usage, response_object
)
@staticmethod
def _billable_web_search_calls(usage: Usage | None, response_object: object) -> int:
"""
Providers without their own calculator but whose usage carries server_tool_use.web_search_requests
(OpenAI-compatible providers that report a per-tool count) are billed that many calls on the per-call path.
"""
reported: Final = None if usage is None else get_web_search_requests_from_usage(usage)
if isinstance(reported, int) and not isinstance(reported, bool) and reported > 0:
return reported
return StandardBuiltInToolCostTracking._count_web_search_calls(response_object)
@staticmethod
def _count_web_search_calls(response_object: object) -> int:

View file

@ -2,9 +2,11 @@
Dynamic configuration class generator for JSON-based providers.
"""
from collections.abc import Coroutine
from collections.abc import Coroutine, Mapping
from typing import TYPE_CHECKING, Final, Literal, overload
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_messages_with_content_list_to_str_conversion,
@ -20,6 +22,26 @@ from .json_loader import SimpleProviderConfig
if TYPE_CHECKING:
from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig
_PAID_SEARCH_TOOLS: Final = frozenset({"web_search", "web_search_image", "image_search"})
_PAYLOAD_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
_TOOL_USAGE_ADAPTER: Final[TypeAdapter[Mapping[str, int]]] = TypeAdapter(Mapping[str, int])
def web_search_requests_from_tool_usage(payload: object) -> int | None:
"""
Billable search calls in a parsed OpenAI-compatible chat response whose usage carries a
tool_usage {tool_name: count} map, as EmpirioLabs reports built-in tool invocations.
Free tools (code_interpreter, web_extractor) are not counted.
"""
try:
body: Final = _PAYLOAD_ADAPTER.validate_python(payload)
usage: Final = _PAYLOAD_ADAPTER.validate_python(body.get("usage"))
tool_usage: Final = _TOOL_USAGE_ADAPTER.validate_python(usage.get("tool_usage"), strict=True)
except ValidationError:
return None
calls: Final = sum(count for tool, count in tool_usage.items() if tool in _PAID_SEARCH_TOOLS and count > 0)
return calls if calls > 0 else None
def create_config_class(provider: SimpleProviderConfig):
"""Generate config class dynamically from JSON configuration"""
@ -179,6 +201,25 @@ def create_config_class(provider: SimpleProviderConfig):
return optional_params
def transform_parsed_response_dict(
self,
parsed_response: dict[str, object], # mutable-ok: BaseConfig signature
) -> dict[str, object]: # mutable-ok: BaseConfig signature
"""
Carry the provider's usage.tool_usage search count as usage.server_tool_use.web_search_requests,
the field the built-in tool cost path bills per call.
"""
repaired: Final = _PAYLOAD_ADAPTER.validate_python(
super().transform_parsed_response_dict(parsed_response) # pyright: ignore[reportUnknownMemberType] # base_class is picked at runtime
)
if not provider.special_handling.get("web_search_requests_from_tool_usage"):
return {**repaired}
web_search_requests: Final = web_search_requests_from_tool_usage(repaired)
if web_search_requests is None:
return {**repaired}
usage: Final = _PAYLOAD_ADAPTER.validate_python(repaired["usage"])
return {**repaired, "usage": {**usage, "server_tool_use": {"web_search_requests": web_search_requests}}}
@property
def custom_llm_provider(self) -> str | None:
return provider.slug

View file

@ -178,7 +178,10 @@
"param_mappings": {
"max_completion_tokens": "max_tokens"
},
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
"special_handling": {
"web_search_requests_from_tool_usage": true
},
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/embeddings"]
},
"meta": {
"base_url": "https://api.meta.ai/v1",

File diff suppressed because it is too large Load diff

View file

@ -5280,10 +5280,10 @@
"display_name": "EmpirioLabs AI (`empiriolabs`)",
"url": "https://docs.litellm.ai/docs/providers/empiriolabs",
"endpoints": {
"chat_completions": false,
"responses": false,
"text_completion": false,
"embeddings": false,
"chat_completions": true,
"responses": true,
"text_completion": true,
"embeddings": true,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
@ -5295,11 +5295,11 @@
"realtime": false,
"video_generations": false,
"batches": false,
"messages": false,
"interactions": false,
"messages": true,
"interactions": true,
"systemone": false,
"a2a": false,
"generateContent": false
"a2a": true,
"generateContent": true
}
},
"openinfer": {

File diff suppressed because it is too large Load diff

View file

@ -5280,10 +5280,10 @@
"display_name": "EmpirioLabs AI (`empiriolabs`)",
"url": "https://docs.litellm.ai/docs/providers/empiriolabs",
"endpoints": {
"chat_completions": false,
"responses": false,
"text_completion": false,
"embeddings": false,
"chat_completions": true,
"responses": true,
"text_completion": true,
"embeddings": true,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
@ -5295,11 +5295,11 @@
"realtime": false,
"video_generations": false,
"batches": false,
"messages": false,
"interactions": false,
"messages": true,
"interactions": true,
"systemone": false,
"a2a": false,
"generateContent": false
"a2a": true,
"generateContent": true
}
},
"openinfer": {

View file

@ -1230,3 +1230,27 @@ def test_vertex_gemini_deployment_priced_web_search_stays_per_prompt(
def test_built_in_tool_lookups_ignore_malformed_request_values(kwargs: dict[str, object]) -> None:
assert StandardBuiltInToolCostTracking.get_web_search_options(kwargs) is None
assert StandardBuiltInToolCostTracking.get_file_search_tool_call(kwargs) is None
def test_per_call_web_search_path_bills_the_count_the_usage_reports(local_model_cost_map):
"""
A provider with no web search calculator of its own (an OpenAI-compatible JSON provider) that
reports server_tool_use.web_search_requests is billed that many per-call searches, not one.
"""
from litellm.types.utils import ServerToolUse, Usage
model = "empiriolabs/qwen3-7-plus"
per_search = litellm.model_cost[model]["search_context_cost_per_query"]["search_context_size_medium"]
response = ModelResponse(model="qwen3-7-plus")
usage = Usage(
prompt_tokens=4, completion_tokens=5, total_tokens=9, server_tool_use=ServerToolUse(web_search_requests=3)
)
cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools(
model=model,
response_object=response,
usage=usage,
custom_llm_provider="empiriolabs",
standard_built_in_tools_params=None,
)
assert cost == pytest.approx(3 * per_search)

View file

@ -139,3 +139,24 @@ class TestForceStoreFalse:
headers={},
)
assert params["store"] is True
class TestWebSearchRequestsFromToolUsage:
def test_counts_only_paid_search_tools(self):
payload = {"usage": {"tool_usage": {"web_search": 2, "image_search": 1, "code_interpreter": 3, "web_extractor": 1}}}
assert dynamic_config.web_search_requests_from_tool_usage(payload) == 3
@pytest.mark.parametrize(
"payload",
[
{"usage": {"prompt_tokens": 1}},
{"usage": {"tool_usage": {"code_interpreter": 2}}},
{"usage": {"tool_usage": {"web_search": "2"}}},
{"usage": {"tool_usage": {"web_search": True}}},
{"usage": {"tool_usage": []}},
{"usage": None},
"not a mapping",
],
)
def test_anything_but_a_positive_search_count_is_none(self, payload):
assert dynamic_config.web_search_requests_from_tool_usage(payload) is None

View file

@ -114,3 +114,67 @@ def test_empiriolabs_chat_models_still_route_through_the_json_provider(
"max_tokens": 16,
}
assert response.choices[0].message.content == "Hello from EmpirioLabs AI"
EMBEDDING_RESPONSE: Final = {
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
"model": "text-embedding-v4",
"usage": {"prompt_tokens": 2, "total_tokens": 2},
}
def test_empiriolabs_embeddings_post_to_v1_embeddings_with_the_bearer_key():
with respx.mock() as upstream:
route: Final = upstream.post(f"{EMPIRIOLABS_BASE_URL}/embeddings").respond(200, json=EMBEDDING_RESPONSE)
response: Final = litellm.embedding(
model="empiriolabs/text-embedding-v4",
input=["Say hello"],
api_key="empiriolabs-test-key",
)
request: Final = route.calls.last.request
body: Final = json.loads(request.content)
assert route.call_count == 1
assert request.headers["authorization"] == "Bearer empiriolabs-test-key"
assert body["model"] == "text-embedding-v4"
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
SEARCHED_CHAT_RESPONSE: Final = {
"id": "chatcmpl_empiriolabs_search",
"object": "chat.completion",
"created": 1_789_550_000,
"model": "qwen3-7-plus",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Two searched answers"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 5,
"total_tokens": 9,
"tool_usage": {"web_search": 2, "code_interpreter": 1},
},
}
def test_empiriolabs_web_search_calls_in_tool_usage_are_billed_per_call(local_model_cost_map: None) -> None:
with respx.mock() as upstream:
upstream.post(f"{EMPIRIOLABS_BASE_URL}/chat/completions").respond(200, json=SEARCHED_CHAT_RESPONSE)
response: Final = litellm.completion(
model="empiriolabs/qwen3-7-plus",
messages=[{"role": "user", "content": "Search twice"}],
api_key="empiriolabs-test-key",
)
row: Final = litellm.model_cost["empiriolabs/qwen3-7-plus"]
token_cost: Final = 4 * row["input_cost_per_token"] + 5 * row["output_cost_per_token"]
per_search: Final = row["search_context_cost_per_query"]["search_context_size_medium"]
assert response.usage.server_tool_use.web_search_requests == 2
assert litellm.completion_cost(
completion_response=response, model="empiriolabs/qwen3-7-plus", custom_llm_provider="empiriolabs"
) == pytest.approx(token_cost + 2 * per_search)