fix(interactions): bind Deep Research agents and record their costs (#51517)

* fix(interactions): bind Deep Research agents to their deployment and bill the agent

A request that sends an agent with a model group now sends the agent to Google
and is refused with a 400 unless the group's deployment is that agent. Agent
interactions are logged and priced under the agent, including streamed and
background jobs, with a Vertex cost row for deep-research-preview-04-2026.
Background cost polling now waits 7800 seconds by default so a 120-minute job
still settles.

* fix(interactions): refuse extra_body that changes the agent or model

A nested agent or model in extra_body was merged into the request after the agent binding check, so it could run a different agent than the one billed, inject an agent onto a model deployment, or add a second selector. Refuse those before any provider or OAuth call, while identical agent repeats and unrelated extra_body fields still pass through.

---------

Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
This commit is contained in:
joshua-berri 2026-11-13 00:20:53 -08:00 • committed by GitHub
parent ac5fd9e0d0
commit 563dfd5075
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 614 additions and 9 deletions

View file

@ -2001,7 +2001,7 @@ BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS: Final = get_env_float(
"BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS", 60.0
)
BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS: Final = get_env_float(
"BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS", 3600.0
"BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS", 7800.0
)
_background_interaction_cost_polling_env: Final = os.getenv(
"BACKGROUND_INTERACTION_COST_POLLING_ENABLED", "true"

View file

@ -126,6 +126,7 @@ from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.agents import LiteLLMSendMessageResponse
from litellm.types.decisions import DecisionsResponse, DecisionsUsage, OpenAIDecisionResponse, OpenAIDecisionUsage
from litellm.types.extraction import ExtractionResponse, ExtractionUsage
from litellm.types.interactions import InteractionsAPIResponse
from litellm.types.llms.base import CachedTokensDetails, LiteLLMBaseModel
from litellm.types.llms.openai import (
HttpxBinaryResponseContent,
@ -1205,6 +1206,8 @@ def _get_response_model(completion_response: object) -> str | None:
if completion_response is None:
return None
if isinstance(completion_response, InteractionsAPIResponse):
return completion_response.model or completion_response.agent
if isinstance(completion_response, BaseModel):
return getattr(completion_response, "model", None)
elif isinstance(completion_response, dict):

View file

@ -300,6 +300,26 @@ def create(
)
else:
custom_llm_provider = custom_llm_provider or "gemini"
if agent and model and model != agent:
raise litellm.BadRequestError(
message=(
f"agent '{agent}' can only run on a deployment of that agent, but the model is '{model}'. "
"Configure a deployment whose model is the agent and send its model group."
),
model=model,
llm_provider=custom_llm_provider,
)
if extra_body and (
("agent" in extra_body and extra_body["agent"] != agent) or (agent and "model" in extra_body)
):
raise litellm.BadRequestError(
message=(
"extra_body cannot set a different agent or add a model to an agent interaction. "
"Send the agent and model as top-level fields."
),
model=model,
llm_provider=custom_llm_provider,
)
interactions_api_config: Final = get_provider_interactions_api_config(
provider=custom_llm_provider,
@ -340,7 +360,7 @@ def create(
litellm_logging_obj.update_from_kwargs(
kwargs=kwargs,
model=model,
model=agent or model,
optional_params=dict(optional_params),
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=custom_llm_provider,

View file

@ -198,11 +198,10 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"""
request_body: Final[dict[str, object]] = {}
# Model or Agent (one required)
if model:
request_body["model"] = GeminiModelInfo.get_base_model(model) or model
elif agent:
if agent:
request_body["agent"] = agent
elif model:
request_body["model"] = GeminiModelInfo.get_base_model(model) or model
else:
raise ValueError("Either 'model' or 'agent' must be provided")
@ -225,7 +224,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
dropped_keys: Final = (
frozenset({"agent_config"})
if drops_unsupported_params(litellm_params.get("drop_params"))
and (model or not _has_agent_config_type(optional_params.get("agent_config")))
and (not agent or not _has_agent_config_type(optional_params.get("agent_config")))
else frozenset()
)
for key in optional_keys:

View file

@ -54503,6 +54503,20 @@
"supports_web_search": true,
"web_search_billing_unit": "per_query"
},
"vertex_ai/deep-research-preview-04-2026": {
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "vertex_ai",
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"source": "https://cloud.google.com/model-garden/pricing",
"web_search_billing_unit": "per_query"
},
"vertex_ai/deep-research-pro-preview-12-2025": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_batches": 1e-07,

View file

@ -54503,6 +54503,20 @@
"supports_web_search": true,
"web_search_billing_unit": "per_query"
},
"vertex_ai/deep-research-preview-04-2026": {
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "vertex_ai",
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"source": "https://cloud.google.com/model-garden/pricing",
"web_search_billing_unit": "per_query"
},
"vertex_ai/deep-research-pro-preview-12-2025": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_batches": 1e-07,

View file

@ -3,7 +3,7 @@ import json
import time
from datetime import datetime, timedelta, timezone
from itertools import islice
from typing import Optional
from typing import Final, Optional
import httpx
import pytest
@ -21,11 +21,13 @@ from litellm.interactions.background_cost_polling import (
poll_and_log_background_interaction_cost,
PollSchedule,
resume_unsettled_background_interactions,
SettlementOutcome,
)
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.types.interactions import InteractionsAPIResponse
from tests._support.clock_loops import AutojumpClockLoop
USAGE_BLOCK = {
"total_tokens": 175,
@ -1422,3 +1424,70 @@ async def test_create_on_a_replica_without_the_settlement_table_still_settles_in
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
class _JobFinishingAt:
def __init__(self, finishes_at: float) -> None:
self.finishes_at: Final = finishes_at
self.last_polled_at: float | None = None
async def fetch(self, context: BackgroundInteractionPollContext) -> InteractionsAPIResponse:
now: Final = asyncio.get_running_loop().time()
self.last_polled_at = now
finished: Final = now >= self.finishes_at
return _response("completed" if finished else "in_progress", with_usage=finished)
async def _finish_owned_loop_tasks() -> None:
current: Final = asyncio.current_task()
leftover: Final = tuple(task for task in asyncio.all_tasks() if task is not current)
for task in leftover:
task.cancel()
await asyncio.gather(*leftover, return_exceptions=True)
def _poll_on_a_virtual_clock(
context: BackgroundInteractionPollContext, job: _JobFinishingAt
) -> SettlementOutcome | None:
loop: Final = AutojumpClockLoop()
try:
return loop.run_until_complete(poll_and_log_background_interaction_cost(context, fetch_interaction=job.fetch))
finally:
loop.run_until_complete(_finish_owned_loop_tasks())
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
loop.close()
_TWO_HOURS_SECONDS: Final = 7200.0
def test_a_background_job_that_runs_past_the_old_one_hour_window_settles_under_the_default_poll_window() -> None:
logging_obj: Final = _logging_obj()
job: Final = _JobFinishingAt(_TWO_HOURS_SECONDS)
context: Final = BackgroundInteractionPollContext(
interaction_id="interactions/bg-abc", custom_llm_provider="gemini", logging_obj=logging_obj
)
outcome: Final = _poll_on_a_virtual_clock(context, job)
assert outcome == "billed"
assert job.last_polled_at is not None and job.last_polled_at >= _TWO_HOURS_SECONDS
assert logging_obj.model_call_details["response_cost"] > 0
def test_an_explicit_shorter_poll_window_still_gives_up_before_a_job_that_outlasts_it() -> None:
logging_obj: Final = _logging_obj()
job: Final = _JobFinishingAt(_TWO_HOURS_SECONDS)
context: Final = BackgroundInteractionPollContext(
interaction_id="interactions/bg-abc",
custom_llm_provider="gemini",
logging_obj=logging_obj,
timeout_seconds=3600.0,
)
outcome: Final = _poll_on_a_virtual_clock(context, job)
assert outcome == "unsettled"
assert job.last_polled_at is not None and job.last_polled_at <= 3600.0
assert logging_obj.model_call_details.get("response_cost") is None

View file

@ -1,3 +1,4 @@
import asyncio
import base64
import json
from collections.abc import Mapping
@ -6,7 +7,8 @@ from typing import Final
import httpx
import pytest
from pydantic import JsonValue, TypeAdapter
import responses
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
from respx import MockRouter
import litellm
@ -96,6 +98,45 @@ def api_key():
return "test-api-key"
_GEMINI_INTERACTIONS_URL: Final = "https://generativelanguage.googleapis.com/v1beta/interactions"
_AGENT: Final = "antigravity-preview-09-2026"
_OTHER_AGENT: Final = "deep-research-preview-04-2026"
_EXTRA_BODY_SELECTOR_OVERRIDES: Final = (
pytest.param(
{"model": f"gemini/{_AGENT}", "agent": _AGENT}, {"agent": _OTHER_AGENT}, id="matching-agent-nested-other-agent"
),
pytest.param({"agent": _AGENT}, {"agent": None}, id="agent-nested-null-agent"),
pytest.param({"model": "gemini/gemini-4-argon"}, {"agent": _AGENT}, id="model-nested-agent"),
pytest.param({"agent": _AGENT}, {"model": "gemini-4-argon"}, id="agent-nested-model"),
pytest.param({"agent": _AGENT}, {"model": _AGENT}, id="agent-nested-model-named-like-the-agent"),
)
_EXTRA_BODY_PASS_THROUGH: Final = (
pytest.param(
{"agent": _AGENT},
{"agent": _AGENT, "store": False},
{"agent": _AGENT, "store": False},
id="same-agent-repeated",
),
pytest.param(
{"agent": _AGENT}, {"custom_flag": 1}, {"agent": _AGENT, "custom_flag": 1}, id="agent-unrelated-extra"
),
pytest.param(
{"model": "gemini/gemini-4-argon"},
{"custom_flag": 1},
{"model": "gemini-4-argon", "custom_flag": 1},
id="model-unrelated-extra",
),
pytest.param(
{"model": "gemini/gemini-4-argon"},
{"model": "gemini-4-carbon"},
{"model": "gemini-4-carbon"},
id="model-only-nested-model",
),
)
class TestGoogleInteractionsCreate:
@pytest.mark.usefixtures("fake_provider_credentials")
def test_missing_model_and_agent(self, api_key):
@ -305,6 +346,15 @@ class TestGoogleInteractionsCreate:
},
id="agent-keeps-an-object",
),
pytest.param(
{"model": "gemini/antigravity-preview-09-2026", "agent": "antigravity-preview-09-2026"},
{"type": "antigravity", "model": "gemini-3.8-flash"},
{
"agent": "antigravity-preview-09-2026",
"agent_config": {"type": "antigravity", "model": "gemini-3.8-flash"},
},
id="matching-model-and-agent-sends-the-agent-with-its-config",
),
pytest.param(
{"agent": "antigravity-preview-09-2026"},
"antigravity",
@ -347,6 +397,45 @@ class TestGoogleInteractionsCreate:
assert json.loads(route.calls.last.request.content) == {**expected_target, "input": "Say hi in one word."}
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_refuses_an_agent_with_another_model_before_any_http_call(self, respx_mock: MockRouter) -> None:
with pytest.raises(litellm.BadRequestError, match="antigravity-preview-09-2026"):
interactions.create(
model="gemini/gemini-2.5-flash",
agent="antigravity-preview-09-2026",
input="Say hi in one word.",
api_key="gemini-offline",
)
assert not respx_mock.calls
@pytest.mark.parametrize(("target", "extra_body"), _EXTRA_BODY_SELECTOR_OVERRIDES)
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_refuses_extra_body_that_changes_the_agent_or_model_before_any_http_call(
self, respx_mock: MockRouter, target: Mapping[str, str], extra_body: Mapping[str, object]
) -> None:
with pytest.raises(litellm.BadRequestError, match="extra_body"):
interactions.create(**target, input="Say hi in one word.", extra_body=extra_body, api_key="gemini-offline")
assert not respx_mock.calls
@pytest.mark.parametrize(("target", "extra_body", "expected_target"), _EXTRA_BODY_PASS_THROUGH)
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_sends_extra_body_that_keeps_the_agent_and_model(
self,
respx_mock: MockRouter,
target: Mapping[str, str],
extra_body: Mapping[str, object],
expected_target: Mapping[str, object],
) -> None:
route: Final = respx_mock.post(_GEMINI_INTERACTIONS_URL).mock(
return_value=httpx.Response(200, json={"id": "interaction-extra", "status": "completed"})
)
interactions.create(**target, input="Say hi in one word.", extra_body=extra_body, api_key="gemini-offline")
assert json.loads(route.calls.last.request.content) == {**expected_target, "input": "Say hi in one word."}
@pytest.mark.usefixtures("fake_provider_credentials")
def test_create_rejects_agent_config_a_bridged_provider_cannot_take(self, respx_mock: MockRouter) -> None:
route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
@ -494,6 +583,39 @@ class TestInteractionsAcreateOffline:
def _httpx_only_transport(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
@pytest.mark.parametrize(("target", "extra_body"), _EXTRA_BODY_SELECTOR_OVERRIDES)
@pytest.mark.usefixtures("fake_provider_credentials")
@pytest.mark.asyncio
async def test_acreate_refuses_extra_body_that_changes_the_agent_or_model_before_any_http_call(
self, respx_mock: MockRouter, target: Mapping[str, str], extra_body: Mapping[str, object]
) -> None:
with pytest.raises(litellm.BadRequestError, match="extra_body"):
await interactions.acreate(
**target, input="Say hi in one word.", extra_body=extra_body, api_key="gemini-offline"
)
assert not respx_mock.calls
@pytest.mark.parametrize(("target", "extra_body", "expected_target"), _EXTRA_BODY_PASS_THROUGH)
@pytest.mark.usefixtures("fake_provider_credentials")
@pytest.mark.asyncio
async def test_acreate_sends_extra_body_that_keeps_the_agent_and_model(
self,
respx_mock: MockRouter,
target: Mapping[str, str],
extra_body: Mapping[str, object],
expected_target: Mapping[str, object],
) -> None:
route: Final = respx_mock.post(_GEMINI_INTERACTIONS_URL).mock(
return_value=httpx.Response(200, json={"id": "interaction-extra", "status": "completed"})
)
await interactions.acreate(
**target, input="Say hi in one word.", extra_body=extra_body, api_key="gemini-offline"
)
assert json.loads(route.calls.last.request.content) == {**expected_target, "input": "Say hi in one word."}
@pytest.mark.usefixtures("fake_provider_credentials")
@pytest.mark.asyncio
async def test_acreate_simple_gemini(self, respx_mock: MockRouter) -> None:
@ -922,3 +1044,134 @@ def test_create_sends_a_caller_continuation_token_to_resume_a_long_decode(respx_
}, sent
assert response.status == "completed"
assert response.continuation_token is None
_DEEP_RESEARCH_AGENT: Final = "deep-research-preview-04-2026"
_DEEP_RESEARCH_USAGE: Final = {
"total_input_tokens": 1000,
"total_cached_tokens": 200,
"total_output_tokens": 300,
"total_thought_tokens": 100,
"total_tokens": 1400,
"input_tokens_by_modality": [{"modality": "text", "tokens": 1000}],
"output_tokens_by_modality": [{"modality": "text", "tokens": 300}],
"grounding_tool_count": [{"type": "google_search", "count": 3}],
}
def _deep_research_cost_from_the_vertex_row() -> float:
row: Final = litellm.model_cost[f"vertex_ai/{_DEEP_RESEARCH_AGENT}"]
return (
(1000 - 200) * row["input_cost_per_token"]
+ 200 * row["cache_read_input_token_cost"]
+ (300 + 100) * row["output_cost_per_token"]
+ 3 * row["search_context_cost_per_query"]["search_context_size_medium"]
)
class _LoggedModelMapValue(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
key: str
class _LoggedModelMap(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
model_map_value: _LoggedModelMapValue
class _LoggedModel(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
model: str
model_map_information: _LoggedModelMap
class _LoggedCost(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
response_cost: float
standard_logging_object: _LoggedModel
class _CostRecorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.logged: Final = asyncio.Event()
self.cost: _LoggedCost | None = None
async def async_log_success_event(
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
) -> None:
self.cost = _LoggedCost.model_validate(kwargs)
self.logged.set()
_DEEP_RESEARCH_INTERACTION: Final = {
"id": "interaction-dr",
"object": "interaction",
"agent": _DEEP_RESEARCH_AGENT,
"status": "completed",
"usage": _DEEP_RESEARCH_USAGE,
}
_DEEP_RESEARCH_URL: Final = (
"https://aiplatform.googleapis.com/v1beta1/projects/dr-project/locations/global/interactions"
)
def _deep_research_reply(stream: bool) -> httpx.Response:
if not stream:
return httpx.Response(200, json=_DEEP_RESEARCH_INTERACTION)
completed: Final = json.dumps({"event_type": "interaction.completed", "interaction": _DEEP_RESEARCH_INTERACTION})
return httpx.Response(200, text=f"data: {completed}\n\n", headers={"content-type": "text/event-stream"})
@pytest.mark.parametrize("stream", [False, True], ids=["background-or-foreground", "streamed"])
@pytest.mark.asyncio
async def test_an_agent_only_vertex_deep_research_interaction_is_billed_under_the_agent_row(
respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch, stream: bool
) -> None:
recorder: Final = _CostRecorder()
monkeypatch.setattr(litellm, "callbacks", [recorder])
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
route: Final = respx_mock.post(f"{_DEEP_RESEARCH_URL}?alt=sse" if stream else _DEEP_RESEARCH_URL).mock(
return_value=_deep_research_reply(stream)
)
credentials: Final = {
"type": "authorized_user",
"client_id": "dr-client",
"client_secret": "dr-secret",
"refresh_token": "dr-refresh-token",
}
with responses.RequestsMock(assert_all_requests_are_fired=False) as google_oauth:
google_oauth.post(
"https://oauth2.googleapis.com/token",
json={"access_token": "dr-token", "expires_in": 3600, "token_type": "Bearer"},
)
response: Final = await interactions.acreate(
agent=_DEEP_RESEARCH_AGENT,
input="Compare grid-scale storage options.",
custom_llm_provider="vertex_ai",
vertex_project="dr-project",
vertex_location="global",
vertex_credentials=json.dumps(credentials),
stream=stream,
)
if stream:
async for _ in response:
pass
await asyncio.wait_for(recorder.logged.wait(), timeout=10)
assert json.loads(route.calls.last.request.content) == {
"agent": _DEEP_RESEARCH_AGENT,
"input": "Compare grid-scale storage options.",
"stream": stream,
}
assert recorder.cost is not None
assert recorder.cost.standard_logging_object.model == _DEEP_RESEARCH_AGENT
assert recorder.cost.standard_logging_object.model_map_information.model_map_value.key == (
f"vertex_ai/{_DEEP_RESEARCH_AGENT}"
)
assert recorder.cost.response_cost == pytest.approx(_deep_research_cost_from_the_vertex_row())

View file

@ -425,6 +425,208 @@ def test_interaction_by_id_naming_a_model_the_key_cannot_call_is_refused_before_
assert not respx_mock.calls
_DEEP_RESEARCH_USAGE: Final = {
"total_input_tokens": 1000,
"total_cached_tokens": 200,
"total_output_tokens": 300,
"total_thought_tokens": 100,
"total_tokens": 1400,
"input_tokens_by_modality": [{"modality": "text", "tokens": 1000}],
"output_tokens_by_modality": [{"modality": "text", "tokens": 300}],
"grounding_tool_count": [{"type": "google_search", "count": 3}],
}
def _deep_research_cost_from_the_vertex_row() -> float:
row: Final = litellm.model_cost[f"vertex_ai/{_DEEP_RESEARCH_AGENT}"]
return (
(1000 - 200) * row["input_cost_per_token"]
+ 200 * row["cache_read_input_token_cost"]
+ (300 + 100) * row["output_cost_per_token"]
+ 3 * row["search_context_cost_per_query"]["search_context_size_medium"]
)
def _deep_research_create(model_group: str) -> Mapping[str, str]:
return {"model": model_group, "agent": _DEEP_RESEARCH_AGENT, "input": "Compare grid-scale storage options."}
def _completed_deep_research(respx_mock: respx.MockRouter) -> respx.Route:
return respx_mock.post(_VERTEX_COLLECTION_URL).mock(
return_value=httpx.Response(
200,
json={
"id": _INTERACTION_ID,
"object": "interaction",
"agent": _DEEP_RESEARCH_AGENT,
"status": "completed",
"usage": _DEEP_RESEARCH_USAGE,
},
)
)
def test_routed_create_with_an_agent_sends_the_agent_with_the_deployment_credentials_and_bills_the_agent(
interactions_proxy: TestClient, respx_mock: respx.MockRouter
) -> None:
upstream: Final = _completed_deep_research(respx_mock)
response: Final = interactions_proxy.post(
"/v1beta/interactions", json=_deep_research_create(_VERTEX_DEEP_RESEARCH_GROUP)
)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == {
"agent": _DEEP_RESEARCH_AGENT,
"input": "Compare grid-scale storage options.",
}
assert upstream.calls.last.request.headers["authorization"] == "Bearer deployment-token"
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(
_deep_research_cost_from_the_vertex_row()
)
def test_routed_create_with_an_agent_on_another_models_deployment_is_refused_before_any_upstream_call(
interactions_proxy: TestClient, respx_mock: respx.MockRouter, google_oauth: responses.RequestsMock
) -> None:
response: Final = interactions_proxy.post("/v1beta/interactions", json=_deep_research_create(_VERTEX_GROUP))
assert response.status_code == 400, response.text
assert _DEEP_RESEARCH_AGENT in response.text
assert not respx_mock.calls
assert not google_oauth.calls
@pytest.mark.parametrize(
"create",
(
pytest.param(
{**_deep_research_create(_VERTEX_DEEP_RESEARCH_GROUP), "extra_body": {"agent": "other-agent"}},
id="nested-other-agent",
),
pytest.param(
{**_deep_research_create(_VERTEX_DEEP_RESEARCH_GROUP), "extra_body": {"model": "gemini-4-argon"}},
id="nested-model-beside-the-agent",
),
pytest.param(
{
"model": _VERTEX_GROUP,
"input": "Compare grid-scale storage options.",
"extra_body": {"agent": _DEEP_RESEARCH_AGENT},
},
id="nested-agent-on-a-model-deployment",
),
),
)
def test_routed_create_with_extra_body_that_changes_the_agent_or_model_is_refused_before_any_upstream_call(
interactions_proxy: TestClient,
respx_mock: respx.MockRouter,
google_oauth: responses.RequestsMock,
create: Mapping[str, object],
) -> None:
response: Final = interactions_proxy.post("/v1beta/interactions", json=create)
assert response.status_code == 400, response.text
assert "extra_body" in response.text
assert not respx_mock.calls
assert not google_oauth.calls
def test_routed_create_with_extra_body_repeating_the_agent_sends_it_once_with_the_other_extras(
interactions_proxy: TestClient, respx_mock: respx.MockRouter
) -> None:
upstream: Final = _completed_deep_research(respx_mock)
response: Final = interactions_proxy.post(
"/v1beta/interactions",
json={
**_deep_research_create(_VERTEX_DEEP_RESEARCH_GROUP),
"extra_body": {"agent": _DEEP_RESEARCH_AGENT, "custom_flag": 1},
},
)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == {
"agent": _DEEP_RESEARCH_AGENT,
"input": "Compare grid-scale storage options.",
"custom_flag": 1,
}
def test_a_key_allowed_the_agent_deployment_runs_the_agent(
interactions_proxy: TestClient, respx_mock: respx.MockRouter
) -> None:
upstream: Final = _completed_deep_research(respx_mock)
with _cached_key(UserAPIKeyAuth(models=[_VERTEX_DEEP_RESEARCH_GROUP])) as authorization:
response: Final = interactions_proxy.post(
"/v1beta/interactions",
json=_deep_research_create(_VERTEX_DEEP_RESEARCH_GROUP),
headers={"authorization": authorization},
)
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content)["agent"] == _DEEP_RESEARCH_AGENT
@pytest.mark.parametrize(
("key", "teams", "model_group", "status_code"),
(
pytest.param(
UserAPIKeyAuth(models=[_VERTEX_GROUP]), (), _VERTEX_GROUP, 400, id="own-group-cannot-run-another-agent"
),
pytest.param(
UserAPIKeyAuth(models=[_VERTEX_GROUP]), (), _VERTEX_DEEP_RESEARCH_GROUP, 403, id="agent-group-not-on-key"
),
pytest.param(
UserAPIKeyAuth(models=[_VERTEX_DEEP_RESEARCH_GROUP]),
(),
_VERTEX_GROUP,
403,
id="other-group-not-on-key",
),
pytest.param(
UserAPIKeyAuth(team_id="team-without-deep-research"),
(LiteLLM_TeamTableCachedObj(team_id="team-without-deep-research", models=[_VERTEX_GROUP]),),
_VERTEX_DEEP_RESEARCH_GROUP,
403,
id="agent-group-not-on-team",
),
),
)
def test_a_restricted_key_cannot_run_an_agent_it_was_not_given_the_deployment_for(
interactions_proxy: TestClient,
respx_mock: respx.MockRouter,
google_oauth: responses.RequestsMock,
key: UserAPIKeyAuth,
teams: tuple[LiteLLM_TeamTableCachedObj, ...],
model_group: str,
status_code: int,
) -> None:
with _cached_key(key, *teams) as authorization:
response: Final = interactions_proxy.post(
"/v1beta/interactions", json=_deep_research_create(model_group), headers={"authorization": authorization}
)
assert response.status_code == status_code, response.text
assert not respx_mock.calls
assert not google_oauth.calls
def test_routed_create_without_an_agent_still_sends_the_deployment_model(
interactions_proxy: TestClient, respx_mock: respx.MockRouter
) -> None:
upstream: Final = respx_mock.post(_VERTEX_COLLECTION_URL).mock(
return_value=httpx.Response(200, json={"id": _INTERACTION_ID, "object": "interaction", "status": "completed"})
)
response: Final = interactions_proxy.post("/v1beta/interactions", json={"model": _VERTEX_GROUP, "input": "Hi"})
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == {"model": "gemini-4-argon", "input": "Hi"}
assert upstream.calls.last.request.headers["authorization"] == "Bearer deployment-token"
def _project_url(project: str) -> str:
return f"https://aiplatform.googleapis.com/v1beta1/projects/{project}/locations/global/interactions"

View file

@ -5250,6 +5250,37 @@ def test_completion_cost_bills_interactions_google_search_per_query():
assert cost > 3 * per_query_cost
def test_completion_cost_bills_an_agent_interaction_at_its_agent_row():
from litellm.types.interactions import InteractionsAPIResponse
agent: Final = "deep-research-preview-04-2026"
model_info: Final = litellm.get_model_info(model=agent, custom_llm_provider="vertex_ai")
response: Final = InteractionsAPIResponse(
id="interactions/agent123",
agent=agent,
status="completed",
usage={
"total_input_tokens": 1000,
"input_tokens_by_modality": [{"modality": "text", "tokens": 1000}],
"total_cached_tokens": 200,
"total_output_tokens": 300,
"output_tokens_by_modality": [{"modality": "text", "tokens": 300}],
"total_thought_tokens": 100,
"grounding_tool_count": [{"type": "google_search", "count": 3}],
},
)
cost: Final = completion_cost(completion_response=response, custom_llm_provider="vertex_ai")
assert model_info["key"] == f"vertex_ai/{agent}"
assert cost == pytest.approx(
800 * model_info["input_cost_per_token"]
+ 200 * model_info["cache_read_input_token_cost"]
+ 400 * model_info["output_cost_per_token"]
+ 3 * model_info["search_context_cost_per_query"]["search_context_size_medium"]
)
def test_completion_cost_bills_interactions_video_output_at_video_rate():
from litellm.types.interactions import InteractionsAPIResponse