Skip to content

Commit e234ee6

Browse files
committed
fix: enforce llm call limits for non-adk runtimes
1 parent 446959c commit e234ee6

6 files changed

Lines changed: 263 additions & 2 deletions

File tree

tests/runtime/codex/test_codex_runtime.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323

2424
import httpx
2525
import pytest
26+
from google.adk.agents import RunConfig
2627
from google.adk.agents.invocation_context import InvocationContext
28+
from google.adk.agents.invocation_context import LlmCallsLimitExceededError
2729
from google.adk.agents.llm_agent import LlmAgent
2830
from google.adk.auth.auth_credential import AuthCredential
2931
from google.adk.auth.auth_credential import AuthCredentialTypes
@@ -867,6 +869,128 @@ async def test_shim_rejects_unknown_invocation_token() -> None:
867869
assert response.status_code == 401
868870

869871

872+
@pytest.mark.asyncio
873+
async def test_shim_counts_each_backend_call_before_tool_loop_retry(
874+
monkeypatch,
875+
) -> None:
876+
shim = ResponsesShim("https://backend.invalid/v1", "backend-key")
877+
agent = LlmAgent(name="assistant", model="model")
878+
ctx = InvocationContext(
879+
session_service=InMemorySessionService(),
880+
invocation_id="inv-1",
881+
agent=agent,
882+
session=Session(
883+
id="session-1",
884+
appName="app",
885+
userId="user",
886+
state={},
887+
events=[],
888+
),
889+
run_config=RunConfig(max_llm_calls=1),
890+
)
891+
backend_calls = 0
892+
893+
async def executor(args, call_id):
894+
return "{}"
895+
896+
token = shim.register_turn(
897+
[{"type": "function", "name": "loop", "parameters": {}}],
898+
{"loop": executor},
899+
before_model_call=ctx.increment_llm_call_count,
900+
)
901+
902+
async def fake_aresponses(**kwargs):
903+
nonlocal backend_calls
904+
backend_calls += 1
905+
if backend_calls > 1:
906+
raise AssertionError("second backend call should be blocked")
907+
return {
908+
"id": "tool",
909+
"model": "model",
910+
"output": [
911+
{
912+
"id": "fc",
913+
"call_id": "call-loop",
914+
"type": "function_call",
915+
"name": "loop",
916+
"arguments": "{}",
917+
"status": "completed",
918+
}
919+
],
920+
}
921+
922+
monkeypatch.setattr("veadk.runtime.codex.proxy.litellm.aresponses", fake_aresponses)
923+
transport = httpx.ASGITransport(app=shim._app)
924+
try:
925+
async with httpx.AsyncClient(
926+
transport=transport, base_url="http://shim"
927+
) as client:
928+
with pytest.raises(LlmCallsLimitExceededError):
929+
await client.post(
930+
"/v1/responses",
931+
headers={"Authorization": f"Bearer {token}"},
932+
json={
933+
"model": "model",
934+
"input": [{"type": "message", "role": "user", "content": "go"}],
935+
},
936+
)
937+
finally:
938+
shim.unregister_turn(token)
939+
940+
assert backend_calls == 1
941+
942+
943+
@pytest.mark.asyncio
944+
async def test_shim_counts_one_plain_backend_call(monkeypatch) -> None:
945+
shim = ResponsesShim("https://backend.invalid/v1", "backend-key")
946+
backend_calls = 0
947+
counted_calls = 0
948+
949+
def before_model_call() -> None:
950+
nonlocal counted_calls
951+
counted_calls += 1
952+
953+
token = shim.register_turn([], {}, before_model_call=before_model_call)
954+
955+
async def fake_aresponses(**kwargs):
956+
nonlocal backend_calls
957+
backend_calls += 1
958+
return {
959+
"id": "final",
960+
"model": "model",
961+
"output": [
962+
{
963+
"id": "msg",
964+
"type": "message",
965+
"role": "assistant",
966+
"status": "completed",
967+
"content": [{"type": "output_text", "text": "done"}],
968+
}
969+
],
970+
}
971+
972+
monkeypatch.setattr("veadk.runtime.codex.proxy.litellm.aresponses", fake_aresponses)
973+
transport = httpx.ASGITransport(app=shim._app)
974+
try:
975+
async with httpx.AsyncClient(
976+
transport=transport, base_url="http://shim"
977+
) as client:
978+
response = await client.post(
979+
"/v1/responses",
980+
headers={"Authorization": f"Bearer {token}"},
981+
json={
982+
"model": "model",
983+
"input": [{"type": "message", "role": "user", "content": "go"}],
984+
},
985+
)
986+
finally:
987+
shim.unregister_turn(token)
988+
989+
assert response.status_code == 200
990+
assert backend_calls == 1
991+
assert counted_calls == 1
992+
993+
870994
@pytest.mark.asyncio
871995
async def test_shim_completes_turn_after_transfer_without_second_model_call(
872996
monkeypatch,

tests/runtime/codex/test_codex_runtime_sdk.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,25 @@ def register_turn(
4646
*,
4747
max_tool_iterations,
4848
invocation_id,
49+
before_model_call=None,
4950
):
5051
self.registered.append(
5152
{
5253
"specs": specs,
5354
"executors": executors,
5455
"max_tool_iterations": max_tool_iterations,
5556
"invocation_id": invocation_id,
57+
"before_model_call": before_model_call,
5658
}
5759
)
5860
return "opaque-turn-token"
5961

6062
def unregister_turn(self, token):
6163
self.unregistered.append(token)
6264

65+
def pop_turn_error(self, token):
66+
return None
67+
6368

6469
class _EmptyStream:
6570
def __aiter__(self):
@@ -129,6 +134,9 @@ class _Context(SimpleNamespace):
129134
def _get_events(self, **kwargs):
130135
return list(self.session.events)
131136

137+
def increment_llm_call_count(self):
138+
return None
139+
132140

133141
@pytest.mark.asyncio
134142
async def test_runtime_passes_isolated_config_and_safe_sdk_controls(

tests/runtime/piagent/test_piagent_runtime.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
from types import SimpleNamespace
2525

2626
import pytest
27+
from google.adk.agents import RunConfig
2728
from google.adk.agents.base_agent import BaseAgent
2829
from google.adk.agents.invocation_context import InvocationContext
30+
from google.adk.agents.invocation_context import LlmCallsLimitExceededError
2931
from google.adk.events.event import Event
3032
from google.adk.models.llm_response import LlmResponse
3133
from google.adk.sessions.in_memory_session_service import InMemorySessionService
@@ -83,10 +85,13 @@ def _fake_ctx(*events: Event):
8385
session=SimpleNamespace(events=list(events), state={}),
8486
branch=None,
8587
plugin_manager=None,
88+
increment_llm_call_count=lambda: None,
8689
)
8790

8891

89-
def _ctx(agent, *events: Event, user_content=None) -> InvocationContext:
92+
def _ctx(
93+
agent, *events: Event, user_content=None, run_config=None
94+
) -> InvocationContext:
9095
return InvocationContext(
9196
session_service=InMemorySessionService(),
9297
invocation_id="inv-1",
@@ -99,6 +104,7 @@ def _ctx(agent, *events: Event, user_content=None) -> InvocationContext:
99104
state={},
100105
events=list(events),
101106
),
107+
run_config=run_config,
102108
)
103109

104110

@@ -1462,6 +1468,94 @@ async def test_piagent_runtime_text_only_end_to_end(tmp_path, monkeypatch):
14621468
assert models["providers"]["veadk"]["models"][0]["id"] == "model-a"
14631469

14641470

1471+
@pytest.mark.asyncio
1472+
async def test_piagent_runtime_counts_one_visible_prompt(tmp_path, monkeypatch):
1473+
binary = _make_fake_pi(tmp_path)
1474+
agent_dir = tmp_path / "agent-home"
1475+
monkeypatch.setenv("PIAGENT_BINARY", str(binary))
1476+
monkeypatch.setenv("PIAGENT_AGENT_DIR", str(agent_dir))
1477+
1478+
agent = Agent(
1479+
name="assistant",
1480+
instruction="Answer briefly.",
1481+
model_name="model-a",
1482+
model_api_base="https://ark.example.com/api/v3/",
1483+
model_api_key="test-key",
1484+
model_api_key_name="",
1485+
runtime="piagent",
1486+
)
1487+
ctx = _fake_ctx(_user_event("ping"))
1488+
counted_calls = 0
1489+
1490+
def increment_llm_call_count() -> None:
1491+
nonlocal counted_calls
1492+
counted_calls += 1
1493+
1494+
ctx.increment_llm_call_count = increment_llm_call_count
1495+
1496+
events = [event async for event in PiAgentRuntime().run_async(agent, ctx)]
1497+
1498+
assert len(events) == 3
1499+
assert counted_calls == 1
1500+
1501+
1502+
@pytest.mark.asyncio
1503+
async def test_piagent_runtime_blocks_prompt_when_llm_call_limit_exceeded(
1504+
tmp_path,
1505+
monkeypatch,
1506+
):
1507+
binary = _make_fake_pi(tmp_path)
1508+
agent_dir = tmp_path / "agent-home"
1509+
monkeypatch.setenv("PIAGENT_BINARY", str(binary))
1510+
monkeypatch.setenv("PIAGENT_AGENT_DIR", str(agent_dir))
1511+
prompt_called = False
1512+
1513+
class FakePiAgentRpcClient:
1514+
def __init__(self, config):
1515+
self.config = config
1516+
1517+
async def __aenter__(self):
1518+
return self
1519+
1520+
async def __aexit__(self, exc_type, exc, tb):
1521+
return None
1522+
1523+
def prompt(self, prompt):
1524+
nonlocal prompt_called
1525+
prompt_called = True
1526+
1527+
async def _events():
1528+
yield {"type": "agent_settled"}
1529+
1530+
return _events()
1531+
1532+
monkeypatch.setattr(
1533+
"veadk.runtime.piagent.runtime.PiAgentRpcClient",
1534+
FakePiAgentRpcClient,
1535+
)
1536+
1537+
agent = Agent(
1538+
name="assistant",
1539+
instruction="Answer briefly.",
1540+
model_name="model-a",
1541+
model_api_base="https://ark.example.com/api/v3/",
1542+
model_api_key="test-key",
1543+
model_api_key_name="",
1544+
runtime="piagent",
1545+
)
1546+
ctx = _ctx(
1547+
agent,
1548+
_user_event("ping"),
1549+
run_config=RunConfig(max_llm_calls=1),
1550+
)
1551+
ctx.increment_llm_call_count()
1552+
1553+
with pytest.raises(LlmCallsLimitExceededError):
1554+
[event async for event in PiAgentRuntime().run_async(agent, ctx)]
1555+
1556+
assert prompt_called is False
1557+
1558+
14651559
@pytest.mark.asyncio
14661560
async def test_piagent_runtime_emits_canonical_bridge_tool_events(
14671561
tmp_path,

veadk/runtime/codex/proxy.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import secrets
3232
import time
3333
from dataclasses import dataclass
34-
from typing import Any, AsyncIterator
34+
from typing import Any, AsyncIterator, Callable
3535

3636
import litellm
3737
import uvicorn
@@ -119,6 +119,7 @@ class ShimTurnContext:
119119
executors: dict[str, Any]
120120
max_tool_iterations: int
121121
invocation_id: str = ""
122+
before_model_call: Callable[[], None] | None = None
122123

123124

124125
class ResponsesShim:
@@ -144,6 +145,7 @@ def __init__(self, api_base: str, api_key: str) -> None:
144145
# subprocess as its provider API key and arrives as a Bearer token, so
145146
# concurrent turns can never overwrite one another's tools/context.
146147
self._turns: dict[str, ShimTurnContext] = {}
148+
self._turn_errors: dict[str, BaseException] = {}
147149
self._app = self._build_app()
148150

149151
def register_turn(
@@ -153,6 +155,7 @@ def register_turn(
153155
*,
154156
max_tool_iterations: int = _AGENT_TOOL_MAX_ITERS,
155157
invocation_id: str = "",
158+
before_model_call: Callable[[], None] | None = None,
156159
) -> str:
157160
"""Register immutable routing state and return its opaque bearer token."""
158161
token = secrets.token_urlsafe(32)
@@ -161,6 +164,7 @@ def register_turn(
161164
executors=dict(executors or {}),
162165
max_tool_iterations=max(1, max_tool_iterations),
163166
invocation_id=invocation_id,
167+
before_model_call=before_model_call,
164168
)
165169
logger.debug(
166170
"codex_shim_turn_registered invocation_id=%s tool_count=%d",
@@ -172,12 +176,27 @@ def register_turn(
172176
def unregister_turn(self, token: str) -> None:
173177
"""Remove one invocation's routing state."""
174178
context = self._turns.pop(token, None)
179+
self._turn_errors.pop(token, None)
175180
if context is not None:
176181
logger.debug(
177182
"codex_shim_turn_unregistered invocation_id=%s",
178183
context.invocation_id,
179184
)
180185

186+
def pop_turn_error(self, token: str) -> BaseException | None:
187+
"""Return and clear an invocation-scoped shim error, if one exists."""
188+
return self._turn_errors.pop(token, None)
189+
190+
def _before_model_call(self, token: str, context: ShimTurnContext) -> None:
191+
callback = context.before_model_call
192+
if callback is None:
193+
return
194+
try:
195+
callback()
196+
except BaseException as e:
197+
self._turn_errors[token] = e
198+
raise
199+
181200
def _build_app(self) -> FastAPI:
182201
app = FastAPI()
183202

@@ -258,6 +277,7 @@ async def responses(request: Request) -> Any:
258277
max_iters = turn_context.max_tool_iterations if agent_executors else 0
259278
iters = 0
260279
while True:
280+
self._before_model_call(token, turn_context)
261281
result = await litellm.aresponses(**call_kwargs)
262282
resp = _to_dict(result)
263283
if max_iters <= 0:

0 commit comments

Comments
 (0)