Skip to content

Commit 52b4acf

Browse files
LukasParkeclaude
andauthored
test: live e2e suite against the real OpenRouter API + CI job (#21)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7c3ef7b commit 52b4acf

6 files changed

Lines changed: 338 additions & 8 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,39 @@ jobs:
4141
- name: Tests
4242
run: uv run pytest tests/unit -q
4343

44+
# Live end-to-end tests against the real OpenRouter API: streaming, a real
45+
# tool round, approval pause/resume, lifecycle hooks, state serialization
46+
# round-trip. Costs a few cents per run (small model, short prompts).
47+
#
48+
# Warns and exits 0 when the secret is missing (e.g. PRs from forks, where
49+
# GitHub withholds secrets) instead of failing — same pattern as upstream
50+
# typescript-agent's e2e job.
51+
e2e:
52+
runs-on: ubuntu-latest
53+
timeout-minutes: 15
54+
steps:
55+
- uses: actions/checkout@v4
56+
57+
- uses: actions/setup-python@v5
58+
with:
59+
python-version: "3.11"
60+
61+
- uses: astral-sh/setup-uv@v5
62+
with:
63+
enable-cache: true
64+
65+
- run: uv sync --all-extras
66+
67+
- name: Live e2e tests
68+
env:
69+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
70+
run: |
71+
if [ -z "$OPENROUTER_API_KEY" ]; then
72+
echo "::warning::OPENROUTER_API_KEY is not set; skipping live e2e tests."
73+
exit 0
74+
fi
75+
uv run pytest tests/e2e -q
76+
4477
# Reports the port's own mechanical gate. Advisory here, BLOCKING inside the
4578
# sync job (scripts/upstream) where it gates whether state.yaml advances.
4679
#

src/openrouter_agent/conversation_state.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import replace
88
from typing import Any, Dict, List, Mapping, Optional, Sequence
99

10-
from ._utils import json_dumps, maybe_await
10+
from ._utils import dump, json_dumps, maybe_await
1111
from .tool_types import (
1212
ConversationState,
1313
ParsedToolCall,
@@ -99,7 +99,10 @@ def serialize_conversation_state(state: ConversationState) -> str:
9999
"""
100100
payload = dataclasses.asdict(state)
101101
payload["version"] = state.version if state.version is not None else CONVERSATION_STATE_VERSION
102-
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
102+
# State built from live responses can hold SDK pydantic items (e.g.
103+
# OutputFunctionCallItem), which dataclasses.asdict passes through
104+
# untouched; dump() them to plain dicts so the wire format stays JSON.
105+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False, default=dump)
103106

104107

105108
def deserialize_conversation_state(raw_json: str) -> ConversationState:

src/openrouter_agent/model_result.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import warnings
88
from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Sequence, Tuple
99

10-
from ._utils import get_field, is_async_iterable, json_dumps, maybe_await, sdk_request_kwargs
10+
from ._utils import dump, get_field, is_async_iterable, json_dumps, maybe_await, sdk_request_kwargs
1111
from .async_params import resolve_async_functions
1212
from .conversation_state import (
1313
append_to_messages,
@@ -138,6 +138,22 @@ def __init__(self, options: Mapping[str, Any]) -> None:
138138
async def _send(self, request: Mapping[str, Any]) -> Any:
139139
client = self.options["client"]
140140
kwargs = sdk_request_kwargs(request)
141+
# Normalize input items at the transport boundary only — internal
142+
# state and stream events keep the upstream TS shapes:
143+
# - Response items echoed back from a live turn are SDK pydantic
144+
# models (e.g. OutputMessageItem); the request validator wants
145+
# plain dicts, so dump() them.
146+
# - Internal items use upstream's camelCase callId; the generated
147+
# Python SDK validates snake_case call_id.
148+
if isinstance(kwargs.get("input"), list):
149+
normalized = []
150+
for item in kwargs["input"]:
151+
if not isinstance(item, Mapping):
152+
item = dump(item)
153+
if isinstance(item, Mapping) and "callId" in item:
154+
item = {("call_id" if k == "callId" else k): v for k, v in item.items()}
155+
normalized.append(item)
156+
kwargs["input"] = normalized
141157
request_options = dict(self.options.get("options") or {})
142158
headers = request_options.pop("headers", None) or request_options.pop("http_headers", None)
143159
if headers:

tests/e2e/test_live_call_model.py

Lines changed: 257 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
"""Live end-to-end tests against the real OpenRouter API.
2+
3+
These exercise the load-bearing loop the same way upstream's
4+
`packages/agent/tests/e2e` suite does: real streaming, a real tool round,
5+
approval pause/resume across two `call_model` calls, lifecycle hooks firing
6+
on live traffic, and state serialization surviving a round trip.
7+
8+
Skipped entirely without OPENROUTER_API_KEY. Uses a small, cheap model —
9+
these tests assert behavior (a tool ran, a hook fired, state advanced),
10+
never model quality, so prompts pin outputs as hard as possible.
11+
"""
12+
113
from __future__ import annotations
214

15+
import json
316
import os
417

518
import pytest
@@ -9,11 +22,251 @@
922
reason="OPENROUTER_API_KEY is required for OpenRouter e2e tests",
1023
)
1124

25+
MODEL = os.getenv("OPENROUTER_E2E_MODEL", "anthropic/claude-haiku-4.5")
26+
27+
28+
def _client(**kwargs):
29+
from openrouter_agent import OpenRouter
30+
31+
return OpenRouter(api_key=os.environ["OPENROUTER_API_KEY"], **kwargs)
1232

13-
async def test_live_call_model_smoke() -> None:
14-
from openrouter_agent import OpenRouter, call_model
1533

16-
client = OpenRouter(api_key=os.environ["OPENROUTER_API_KEY"])
17-
result = call_model(client, {"model": "openai/gpt-4o-mini", "input": "Reply with the word pong."})
34+
class MemoryState:
35+
def __init__(self):
36+
self.current = None
37+
self.saved = []
38+
39+
async def load(self):
40+
return self.current
41+
42+
async def save(self, new_state):
43+
self.current = new_state
44+
self.saved.append(new_state)
45+
46+
47+
async def test_live_text_and_stream_agree() -> None:
48+
"""Basic call: streamed deltas concatenate to the same final text."""
49+
from openrouter_agent import call_model
50+
51+
result = call_model(
52+
_client(),
53+
{"model": MODEL, "input": "Reply with exactly the word: pong"},
54+
)
55+
chunks = [chunk async for chunk in result.get_text_stream()]
1856
text = await result.get_text()
57+
1958
assert "pong" in text.lower()
59+
assert "".join(chunks) == text
60+
61+
62+
async def test_live_tool_loop_executes_and_feeds_result_back() -> None:
63+
"""The model calls our tool, and the tool's output shapes the final answer."""
64+
from openrouter_agent import call_model, tool
65+
66+
calls = []
67+
68+
def lookup(params, ctx):
69+
calls.append(params)
70+
return {"secret": "BANANA-42"}
71+
72+
secret_tool = tool(
73+
name="get_secret",
74+
description="Returns the secret code. Call this to answer any question about the secret code.",
75+
input_schema=dict,
76+
execute=lookup,
77+
)
78+
79+
result = call_model(
80+
_client(),
81+
{
82+
"model": MODEL,
83+
"input": "What is the secret code? Use the get_secret tool, then repeat the code back verbatim.",
84+
"tools": [secret_tool],
85+
# No tool_choice="required" here: it persists across follow-up
86+
# turns (matching upstream), which forces tool calls forever and
87+
# trips the 20-turn safety limit. The approval tests can use it
88+
# because they pause after the first turn.
89+
},
90+
)
91+
text = await result.get_text()
92+
93+
assert len(calls) >= 1, "model never called the tool"
94+
assert "BANANA-42" in text
95+
tool_calls = await result.get_tool_calls()
96+
assert "get_secret" in [c.name for c in tool_calls]
97+
98+
99+
async def test_live_approval_pause_and_resume_across_calls() -> None:
100+
"""require_approval pauses the run with state persisted; a second
101+
call_model with approve_tool_calls resumes, executes, and completes.
102+
103+
This is the mixed approval/HITL turn-ordering surface the port review
104+
flagged as the thing to watch — run against the real API.
105+
"""
106+
from openrouter_agent import call_model, tool
107+
108+
executed = []
109+
110+
delete_tool = tool(
111+
name="delete_record",
112+
description="Deletes the record. Requires approval.",
113+
input_schema=dict,
114+
output_schema=dict,
115+
execute=lambda params, ctx: executed.append(params) or {"deleted": True},
116+
require_approval=True,
117+
)
118+
119+
state = MemoryState()
120+
first = call_model(
121+
_client(),
122+
{
123+
"model": MODEL,
124+
"input": "Delete the record with id 7 using the delete_record tool.",
125+
"tools": [delete_tool],
126+
"state": state,
127+
# Force the tool call: this test asserts the approval pause, not
128+
# the model's willingness to use tools. Without this the model
129+
# occasionally answers in prose and the run legitimately completes.
130+
"tool_choice": "required",
131+
},
132+
)
133+
await first.get_response()
134+
135+
paused = state.current
136+
assert paused is not None, "no state was saved"
137+
assert paused.status == "awaiting_approval"
138+
assert executed == [], "tool must not run before approval"
139+
140+
pending = await first.get_pending_tool_calls()
141+
assert len(pending) == 1
142+
call_id = pending[0].id
143+
144+
resumed = call_model(
145+
_client(),
146+
{
147+
"model": MODEL,
148+
"input": [],
149+
"tools": [delete_tool],
150+
"state": state,
151+
"approve_tool_calls": [call_id],
152+
},
153+
)
154+
text = await resumed.get_text()
155+
156+
assert len(executed) == 1, "approved tool did not execute exactly once"
157+
assert state.current.status == "complete"
158+
assert isinstance(text, str) and text.strip()
159+
160+
161+
async def test_live_hooks_fire_on_real_traffic() -> None:
162+
"""PreToolUse / PostToolUse / SessionStart / SessionEnd / PostModelCall
163+
all fire during a live tool round, and SessionEnd reports real usage."""
164+
from openrouter_agent import HookEntry, HookName, HooksManager, call_model, tool
165+
166+
fired = []
167+
usage_totals = {}
168+
169+
manager = HooksManager()
170+
for hook_name in (
171+
HookName.SessionStart,
172+
HookName.PreToolUse,
173+
HookName.PostToolUse,
174+
HookName.PostModelCall,
175+
):
176+
manager.on(
177+
hook_name.value,
178+
HookEntry(handler=lambda payload, ctx, _n=hook_name.value: fired.append(_n) or {}),
179+
)
180+
181+
def session_end(payload, ctx):
182+
fired.append(HookName.SessionEnd.value)
183+
usage_totals.update(payload.get("total_usage") or {})
184+
return {}
185+
186+
manager.on(HookName.SessionEnd.value, HookEntry(handler=session_end))
187+
188+
echo = tool(
189+
name="echo",
190+
description="Echoes back the given text.",
191+
input_schema=dict,
192+
execute=lambda params, ctx: {"echoed": params.get("text", "")},
193+
)
194+
195+
result = call_model(
196+
_client(),
197+
{
198+
"model": MODEL,
199+
"input": "Use the echo tool with text 'hi', then say done.",
200+
"tools": [echo],
201+
"hooks": manager,
202+
},
203+
)
204+
await result.get_text()
205+
206+
assert fired[0] == HookName.SessionStart.value
207+
assert fired[-1] == HookName.SessionEnd.value
208+
assert HookName.PreToolUse.value in fired
209+
assert HookName.PostToolUse.value in fired
210+
assert HookName.PostModelCall.value in fired
211+
# SessionEnd carries aggregated real usage — a live call must cost tokens.
212+
assert any(v for v in usage_totals.values() if isinstance(v, (int, float)) and v > 0), (
213+
f"SessionEnd usage totals empty: {usage_totals}"
214+
)
215+
216+
217+
async def test_live_state_serialization_round_trip_resumes() -> None:
218+
"""A live paused state survives serialize -> JSON -> deserialize and the
219+
deserialized state resumes correctly — the durable-storage story works
220+
against real response ids, not just fixtures."""
221+
from openrouter_agent import (
222+
call_model,
223+
deserialize_conversation_state,
224+
serialize_conversation_state,
225+
tool,
226+
)
227+
228+
executed = []
229+
approve_tool = tool(
230+
name="launch",
231+
description="Launches the rocket. Requires approval.",
232+
input_schema=dict,
233+
output_schema=dict,
234+
execute=lambda params, ctx: executed.append(1) or {"launched": True},
235+
require_approval=True,
236+
)
237+
238+
state = MemoryState()
239+
first = call_model(
240+
_client(),
241+
{
242+
"model": MODEL,
243+
"input": "Launch the rocket using the launch tool.",
244+
"tools": [approve_tool],
245+
"state": state,
246+
"tool_choice": "required", # see approval test: pin the tool call
247+
},
248+
)
249+
await first.get_response()
250+
assert state.current.status == "awaiting_approval"
251+
pending = await first.get_pending_tool_calls()
252+
253+
# Round-trip through the wire format, as a durable store would.
254+
raw = serialize_conversation_state(state.current)
255+
json.loads(raw) # must be valid JSON, not repr()
256+
restored = MemoryState()
257+
restored.current = deserialize_conversation_state(raw)
258+
259+
resumed = call_model(
260+
_client(),
261+
{
262+
"model": MODEL,
263+
"input": [],
264+
"tools": [approve_tool],
265+
"state": restored,
266+
"approve_tool_calls": [pending[0].id],
267+
},
268+
)
269+
await resumed.get_text()
270+
271+
assert executed == [1]
272+
assert restored.current.status == "complete"

0 commit comments

Comments
 (0)