Files
wehub-resource-sync f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
chore: import upstream snapshot with attribution
2026-07-13 12:31:48 +08:00

1345 lines
49 KiB
Python

"""Tests for WebSocket proxy support in reverse proxy mode."""
import asyncio
import json
import os
import shutil
import tempfile
import tracemalloc
from pathlib import Path
import aiohttp
import pytest
from aiohttp import web
from yarl import URL
from claude_tap.cli_clients import _extend_no_proxy
from claude_tap.proxy import proxy_handler
from claude_tap.trace import TraceWriter
from claude_tap.trace_store import get_trace_store, reset_trace_store
from claude_tap.ws_proxy import _build_ws_record, _get_ws_proxy_settings
@pytest.fixture
def trace_dir():
d = tempfile.mkdtemp(prefix="claude_tap_ws_test_")
saved_no_proxy = {key: os.environ.get(key) for key in ("NO_PROXY", "no_proxy")}
_extend_no_proxy(os.environ, ("localhost", "127.0.0.1", "::1"))
os.environ["CLOUDTAP_DB"] = str(Path(d) / "ws-test.sqlite3")
reset_trace_store()
yield d
for key, value in saved_no_proxy.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
shutil.rmtree(d, ignore_errors=True)
reset_trace_store()
def _make_writer() -> tuple[object, str, TraceWriter]:
store = get_trace_store()
session_id = store.create_session()
return store, session_id, TraceWriter(session_id, store=store)
def _load_records(store, session_id: str) -> list[dict]:
return store.load_records(session_id)
async def _start_ws_upstream(handler) -> tuple[web.AppRunner, int]:
"""Start a fake WebSocket upstream server, return (runner, port)."""
app = web.Application()
app.router.add_route("*", "/{path:.*}", handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
return runner, port
async def _start_proxy(
target_url,
writer,
strip_prefix="",
store_stream_events=False,
capture_only=False,
) -> tuple[web.AppRunner, int, aiohttp.ClientSession]:
"""Start the reverse proxy, return (runner, port, session)."""
session = aiohttp.ClientSession(auto_decompress=False, trust_env=True)
app = web.Application(client_max_size=0)
app["trace_ctx"] = {
"target_url": target_url,
"writer": writer,
"session": session,
"turn_counter": 0,
"strip_path_prefix": strip_prefix,
"store_stream_events": store_stream_events,
"capture_only": capture_only,
}
app.router.add_route("*", "/{path_info:.*}", proxy_handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
return runner, port, session
@pytest.mark.asyncio
async def test_websocket_capture_only_reverse_skips_upstream_and_reads_prompt_frame(trace_dir, monkeypatch):
"""Reverse WebSocket capture-only accepts locally and waits past setup frames for the prompt."""
store, session_id, writer = _make_writer()
async def ws_upstream_handler(_request):
raise AssertionError("capture-only websocket must not connect upstream")
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
store_stream_events=True,
capture_only=True,
)
def fail_ws_connect(*args, **kwargs):
raise AssertionError("capture-only websocket must not call session.ws_connect")
monkeypatch.setattr(proxy_session, "ws_connect", fail_ws_connect)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"type": "session.update", "tools": []})
await ws.send_json(
{
"type": "response.create",
"model": "gpt-test",
"instructions": "ws system",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
}
)
received = []
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
received.append(json.loads(msg.data))
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING):
break
await ws.close()
assert [event["type"] for event in received] == ["response.created", "response.completed"]
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 1
assert records[0]["request"]["body"]["instructions"] == "ws system"
assert len(records[0]["request"]["ws_events"]) == 2
assert records[0]["response"]["body"]["status"] == "completed"
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
# ---------------------------------------------------------------------------
# Test 1: basic WebSocket relay and trace recording
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_websocket_proxy_basic(trace_dir):
"""A WebSocket connection through the proxy relays messages and writes a trace."""
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
model = data.get("model", "test-model")
await ws.send_json(
{
"type": "response.created",
"response": {"id": "resp_1", "model": model, "status": "in_progress"},
}
)
await ws.send_json({"type": "response.output_text.delta", "delta": "Hello "})
await ws.send_json({"type": "response.output_text.delta", "delta": "World"})
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"model": model,
"status": "completed",
"output": [
{
"type": "message",
"content": [{"type": "output_text", "text": "Hello World"}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5},
},
}
)
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "gpt-test", "input": "hello"})
received = []
while True:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
if msg.type == aiohttp.WSMsgType.TEXT:
received.append(json.loads(msg.data))
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
break
await ws.close()
# Verify relayed messages
assert len(received) == 4
assert received[0]["type"] == "response.created"
assert received[1]["type"] == "response.output_text.delta"
assert received[3]["type"] == "response.completed"
assert received[3]["response"]["usage"]["output_tokens"] == 5
# Allow trace writer to flush
await asyncio.sleep(0.1)
writer.close()
records = store.load_records(session_id)
assert len(records) == 1
r = records[0]
assert r["transport"] == "websocket"
assert r["request"]["method"] == "WEBSOCKET"
assert r["request"]["path"] == "/v1/responses"
assert r["request"]["body"]["model"] == "gpt-test"
assert "ws_events" not in r["request"]
assert r["response"]["status"] == 101
assert "ws_events" not in r["response"]
assert r["response"]["body"]["status"] == "completed"
assert r["upstream_base_url"] == f"http://127.0.0.1:{upstream_port}"
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_completed_response_is_written_before_socket_close(trace_dir):
"""A completed WS response should be visible before a long-lived socket closes."""
allow_close = asyncio.Event()
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
model = data.get("model", "test-model")
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_live",
"model": model,
"status": "completed",
"output": [{"type": "message", "content": [{"type": "output_text", "text": "done"}]}],
"usage": {"input_tokens": 3, "output_tokens": 1},
},
}
)
await allow_close.wait()
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "gpt-test", "input": "hello"})
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["type"] == "response.completed"
await asyncio.sleep(0.1)
records = store.load_records(session_id)
assert len(records) == 1
assert records[0]["response"]["body"]["status"] == "completed"
assert records[0]["request"]["body"]["model"] == "gpt-test"
allow_close.set()
await ws.close()
await asyncio.sleep(0.1)
writer.close()
records = store.load_records(session_id)
assert len(records) == 1
finally:
allow_close.set()
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_slow_writer_does_not_clear_next_request(trace_dir):
"""New client messages arriving during a slow trace write belong to the next record."""
class SlowFirstWrite:
def __init__(self, inner: TraceWriter):
self.inner = inner
self.first_write_started = asyncio.Event()
self.release_first_write = asyncio.Event()
self.write_count = 0
async def write(self, record):
self.write_count += 1
if self.write_count == 1:
self.first_write_started.set()
await self.release_first_write.wait()
await self.inner.write(record)
def close(self):
self.inner.close()
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
data = json.loads(msg.data)
response_id = data["response_id"]
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": response_id,
"model": data.get("model", "test-model"),
"status": "completed",
"output": [{"type": "message", "content": [{"type": "output_text", "text": response_id}]}],
"usage": {"input_tokens": 3, "output_tokens": 1},
},
}
)
return ws
store, session_id, inner_writer = _make_writer()
writer = SlowFirstWrite(inner_writer)
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "gpt-test", "response_id": "resp1", "input": "first"})
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["response"]["id"] == "resp1"
await asyncio.wait_for(writer.first_write_started.wait(), timeout=5)
await ws.send_json({"model": "gpt-test", "response_id": "resp2", "input": "second"})
await asyncio.sleep(0.05)
writer.release_first_write.set()
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["response"]["id"] == "resp2"
await ws.close()
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 2
assert records[0]["request"]["body"]["response_id"] == "resp1"
assert records[0]["response"]["body"]["id"] == "resp1"
assert records[1]["request"]["body"]["response_id"] == "resp2"
assert records[1]["response"]["body"]["id"] == "resp2"
finally:
writer.release_first_write.set()
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_completed_snapshot_before_client_send_returns(trace_dir, monkeypatch):
"""A next request sent while completed is still being relayed must not join the old record."""
first_completed_sent = asyncio.Event()
release_first_send = asyncio.Event()
original_send_str = web.WebSocketResponse.send_str
async def delayed_send_str(self, data, *args, **kwargs):
result = await original_send_str(self, data, *args, **kwargs)
try:
parsed = json.loads(data)
except json.JSONDecodeError:
return result
if parsed.get("type") == "response.completed" and (parsed.get("response") or {}).get("id") == "resp1":
first_completed_sent.set()
await release_first_send.wait()
return result
monkeypatch.setattr(web.WebSocketResponse, "send_str", delayed_send_str)
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
data = json.loads(msg.data)
response_id = data["response_id"]
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": response_id,
"model": data.get("model", "test-model"),
"status": "completed",
"output": [{"type": "message", "content": [{"type": "output_text", "text": response_id}]}],
"usage": {"input_tokens": 3, "output_tokens": 1},
},
}
)
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "gpt-test", "response_id": "resp1", "input": "first"})
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["response"]["id"] == "resp1"
await asyncio.wait_for(first_completed_sent.wait(), timeout=5)
await ws.send_json({"model": "gpt-test", "response_id": "resp2", "input": "second"})
await asyncio.sleep(0.05)
release_first_send.set()
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["response"]["id"] == "resp2"
await ws.close()
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 2
assert records[0]["request"]["body"]["response_id"] == "resp1"
assert records[0]["response"]["body"]["id"] == "resp1"
assert records[1]["request"]["body"]["response_id"] == "resp2"
assert records[1]["response"]["body"]["id"] == "resp2"
finally:
release_first_send.set()
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_completed_snapshot_survives_client_close_during_write(trace_dir):
"""A popped completed snapshot must still be written if the client closes immediately."""
class SlowFirstWrite:
def __init__(self, inner: TraceWriter):
self.inner = inner
self.first_write_started = asyncio.Event()
self.release_first_write = asyncio.Event()
self.write_count = 0
async def write(self, record):
self.write_count += 1
if self.write_count == 1:
self.first_write_started.set()
await self.release_first_write.wait()
await self.inner.write(record)
def close(self):
self.inner.close()
allow_upstream_close = asyncio.Event()
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
data = json.loads(msg.data)
response_id = data["response_id"]
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": response_id,
"model": data.get("model", "test-model"),
"status": "completed",
"output": [{"type": "message", "content": [{"type": "output_text", "text": response_id}]}],
"usage": {"input_tokens": 3, "output_tokens": 1},
},
}
)
await allow_upstream_close.wait()
await ws.close()
break
return ws
store, session_id, inner_writer = _make_writer()
writer = SlowFirstWrite(inner_writer)
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "gpt-test", "response_id": "resp1", "input": "first"})
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["response"]["id"] == "resp1"
await asyncio.wait_for(writer.first_write_started.wait(), timeout=5)
close_task = asyncio.create_task(ws.close())
await asyncio.sleep(0.05)
assert _load_records(store, session_id) == []
writer.release_first_write.set()
await asyncio.wait_for(close_task, timeout=5)
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 1
assert records[0]["request"]["body"]["response_id"] == "resp1"
assert records[0]["response"]["body"]["id"] == "resp1"
finally:
writer.release_first_write.set()
allow_upstream_close.set()
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_duplicate_completed_keeps_pending_trailing_events(trace_dir):
"""Duplicate terminal events should not discard pending non-terminal events."""
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_same",
"status": "completed",
"output": [],
},
}
)
await ws.send_json(
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "LATE_TEXT"}],
},
}
)
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_same",
"status": "completed",
"output": [],
},
}
)
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
store_stream_events=True,
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "gpt-test", "input": "hello"})
received = []
while True:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
if msg.type == aiohttp.WSMsgType.TEXT:
received.append(json.loads(msg.data))
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
break
await ws.close()
assert [event["type"] for event in received] == [
"response.completed",
"response.output_item.done",
"response.completed",
]
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 2
assert [event["type"] for event in records[0]["response"]["ws_events"]] == ["response.completed"]
assert [event["type"] for event in records[1]["response"]["ws_events"]] == [
"response.output_item.done",
]
assert records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "LATE_TEXT"
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_duplicate_completed_trailing_events_do_not_move_to_next_response(trace_dir):
"""Trailing events after a duplicate terminal event must not be attributed to the next response."""
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
received = 0
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
received += 1
if received == 1:
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_same",
"status": "completed",
"output": [],
},
}
)
await ws.send_json(
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "LATE_TEXT"}],
},
}
)
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_same",
"status": "completed",
"output": [],
},
}
)
continue
await ws.send_json({"type": "response.output_text.delta", "delta": "SECOND"})
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_two",
"status": "completed",
"output": [],
},
}
)
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
store_stream_events=True,
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"response_id": "req1", "model": "gpt-test", "input": "first"})
for _ in range(3):
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
await asyncio.sleep(0.1)
partial_records = _load_records(store, session_id)
assert len(partial_records) == 2
assert [event["type"] for event in partial_records[1]["response"]["ws_events"]] == [
"response.output_item.done"
]
assert partial_records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "LATE_TEXT"
await ws.send_json({"response_id": "req2", "model": "gpt-test", "input": "second"})
while True:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
break
assert msg.type == aiohttp.WSMsgType.TEXT
await ws.close()
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 3
assert records[0]["request"]["body"]["response_id"] == "req1"
assert [event["type"] for event in records[0]["response"]["ws_events"]] == ["response.completed"]
assert records[1]["request"]["body"] is None
assert [event["type"] for event in records[1]["response"]["ws_events"]] == ["response.output_item.done"]
assert records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "LATE_TEXT"
assert records[2]["request"]["body"]["response_id"] == "req2"
assert [event["type"] for event in records[2]["response"]["ws_events"]] == [
"response.output_text.delta",
"response.completed",
]
assert "LATE_TEXT" not in json.dumps(records[2]["response"]["body"], ensure_ascii=False)
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_duplicate_completed_does_not_flush_next_request(trace_dir):
"""Duplicate terminal events must not split a buffered next request from its response."""
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
received = 0
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
received += 1
if received == 1:
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_same",
"status": "completed",
"output": [],
},
}
)
continue
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_same",
"status": "completed",
"output": [],
},
}
)
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": "resp_two",
"status": "completed",
"output": [],
},
}
)
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"response_id": "req1", "model": "gpt-test", "input": "first"})
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert json.loads(msg.data)["response"]["id"] == "resp_same"
await ws.send_json({"response_id": "req2", "model": "gpt-test", "input": "second"})
received = []
while True:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
break
assert msg.type == aiohttp.WSMsgType.TEXT
received.append(json.loads(msg.data)["response"]["id"])
await ws.close()
assert received == ["resp_same", "resp_two"]
await asyncio.sleep(0.1)
writer.close()
records = _load_records(store, session_id)
assert len(records) == 2
assert records[0]["request"]["body"]["response_id"] == "req1"
assert records[0]["response"]["body"]["id"] == "resp_same"
assert records[1]["request"]["body"]["response_id"] == "req2"
assert records[1]["response"]["body"]["id"] == "resp_two"
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
@pytest.mark.asyncio
async def test_websocket_completed_segments_do_not_accumulate_in_memory(trace_dir):
"""Completed WS response segments should be released while the socket stays open."""
allow_close = asyncio.Event()
response_count = 200
payload = "x" * 8_000
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
received = 0
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
response_id = data["response_id"]
await ws.send_json({"type": "response.output_text.delta", "delta": payload})
await ws.send_json(
{
"type": "response.completed",
"response": {
"id": response_id,
"status": "completed",
"output": [],
},
}
)
received += 1
if received == response_count:
await allow_close.wait()
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
try:
tracemalloc.start()
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
for index in range(response_count):
await ws.send_json(
{
"type": "response.create",
"response_id": f"resp_{index}",
"model": "gpt-test",
"input": payload,
}
)
for _ in range(2):
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
await asyncio.sleep(0.1)
current_bytes, peak_bytes = tracemalloc.get_traced_memory()
record_count = len(_load_records(store, session_id))
assert record_count == response_count
allow_close.set()
await ws.close()
tracemalloc.stop()
writer.close()
assert current_bytes < 2_000_000
assert peak_bytes < 8_000_000
finally:
allow_close.set()
if tracemalloc.is_tracing():
tracemalloc.stop()
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
# ---------------------------------------------------------------------------
# Test 2: upstream ws_connect should inherit trust_env proxy behavior
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_websocket_upstream_connect_does_not_override_proxy(trace_dir):
"""Upstream ws_connect must not force proxy=None; rely on session trust_env."""
async def ws_upstream_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_str(msg.data)
await ws.close()
break
return ws
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
strip_prefix="/v1",
)
ws_connect_calls: list[dict] = []
original_ws_connect = proxy_session.ws_connect
async def _spy_ws_connect(*args, **kwargs):
ws_connect_calls.append(dict(kwargs))
return await original_ws_connect(*args, **kwargs)
proxy_session.ws_connect = _spy_ws_connect # type: ignore[method-assign]
try:
async with aiohttp.ClientSession() as client:
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_str("hello")
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
assert msg.data == "hello"
await ws.close()
assert ws_connect_calls
assert "proxy" not in ws_connect_calls[0]
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
# ---------------------------------------------------------------------------
# Test 3: upstream WebSocket failure returns 502
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_websocket_upstream_failure(trace_dir):
"""When upstream WS connect fails, return HTTP 502 and record the error."""
store, session_id, writer = _make_writer()
# Point proxy at a port where nothing is listening
proxy_runner, proxy_port, proxy_session = await _start_proxy(
"http://127.0.0.1:19999",
writer,
strip_prefix="/v1",
)
try:
async with aiohttp.ClientSession() as client:
# Attempt WebSocket upgrade — proxy should return 502
with pytest.raises(aiohttp.WSServerHandshakeError) as exc_info:
await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
assert exc_info.value.status == 502
await asyncio.sleep(0.1)
writer.close()
records = store.load_records(session_id)
assert len(records) == 1
r = records[0]
assert r["transport"] == "websocket"
assert r["response"]["status"] == 502
assert r["response"]["error"]
finally:
await proxy_session.close()
await proxy_runner.cleanup()
def test_build_ws_record_merges_incremental_request_and_output_items() -> None:
record = _build_ws_record(
req_id="req_test",
turn=1,
duration_ms=25,
path_qs="/v1/responses",
req_headers={"Authorization": "Bearer test-token"},
client_messages=[
json.dumps(
{
"type": "response.create",
"model": "gpt-5.4",
"instructions": "You are Codex.",
"input": [],
"tools": [{"type": "function", "name": "exec_command"}],
}
),
json.dumps(
{
"type": "response.create",
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "你好,调用一个工具,然后结束"}]}
],
}
),
json.dumps(
{
"type": "response.create",
"previous_response_id": "resp_previous",
"input": [
{
"type": "function_call_output",
"call_id": "call_1",
"output": "tool output",
}
],
}
),
],
server_messages=[
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 0},
},
}
),
json.dumps(
{
"type": "response.output_item.done",
"output_index": 1,
"item": {
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "HELLO_FROM_WS"}],
},
}
),
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 2},
},
}
),
],
upstream_base_url="https://chatgpt.com/backend-api/codex",
)
assert record["request"]["body"]["input"][0]["content"][0]["text"] == "你好,调用一个工具,然后结束"
assert record["request"]["body"]["input"][1]["type"] == "function_call_output"
assert record["request"]["body"]["previous_response_id"] == "resp_previous"
assert record["request"]["body"]["tools"][0]["name"] == "exec_command"
assert len(record["request"]["ws_events"]) == 3
assert record["request"]["ws_events"][1]["input"][0]["content"][0]["text"] == "你好,调用一个工具,然后结束"
assert record["request"]["ws_events"][2]["input"][0]["type"] == "function_call_output"
assert record["response"]["body"]["usage"] == {"input_tokens": 10, "output_tokens": 2}
assert record["response"]["body"]["output"][0]["content"][0]["text"] == "HELLO_FROM_WS"
def test_build_ws_record_can_omit_raw_events_after_reconstruction() -> None:
record = _build_ws_record(
req_id="req_compact",
turn=1,
duration_ms=25,
path_qs="/v1/responses",
req_headers={},
client_messages=[json.dumps({"model": "gpt-5.4", "input": "hello"})],
server_messages=[
json.dumps(
{
"type": "response.completed",
"response": {
"id": "resp_1",
"status": "completed",
"output": [{"type": "message", "content": [{"type": "output_text", "text": "OK"}]}],
"usage": {"input_tokens": 2, "output_tokens": 1},
},
}
)
],
upstream_base_url="https://chatgpt.com/backend-api/codex",
store_stream_events=False,
)
assert "ws_events" not in record["request"]
assert "ws_events" not in record["response"]
assert record["request"]["body"]["input"] == "hello"
assert record["response"]["body"]["output"][0]["content"][0]["text"] == "OK"
def test_build_ws_record_treats_empty_error_string_as_failure() -> None:
record = _build_ws_record(
req_id="req_fail",
turn=1,
duration_ms=25,
path_qs="/v1/responses",
req_headers={},
client_messages=[],
server_messages=[],
upstream_base_url="https://chatgpt.com/backend-api/codex",
error="",
)
assert record["response"]["status"] == 502
assert record["response"]["error"] == ""
# ---------------------------------------------------------------------------
# Test 4: WebSocket coexists with HTTP — mixed traffic
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_websocket_and_http_coexist(trace_dir):
"""Both HTTP and WebSocket requests through the same proxy are recorded."""
async def mixed_handler(request):
if request.headers.get("Upgrade", "").lower() == "websocket":
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_json({"type": "response.completed", "response": {"id": "ws_1"}})
await ws.close()
break
return ws
else:
body = await request.json()
return web.json_response(
{
"id": "http_1",
"content": [{"type": "text", "text": "HTTP response"}],
"model": body.get("model", "test"),
"usage": {"input_tokens": 5, "output_tokens": 3},
}
)
store, session_id, writer = _make_writer()
upstream_runner, upstream_port = await _start_ws_upstream(mixed_handler)
proxy_runner, proxy_port, proxy_session = await _start_proxy(
f"http://127.0.0.1:{upstream_port}",
writer,
)
try:
async with aiohttp.ClientSession() as client:
# HTTP request
resp = await client.post(
f"http://127.0.0.1:{proxy_port}/v1/messages",
json={"model": "test-model", "messages": [{"role": "user", "content": "hi"}]},
)
assert resp.status == 200
await resp.json()
# WebSocket request
ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses")
await ws.send_json({"model": "test-model", "input": "ws hello"})
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
# Wait for close
await asyncio.wait_for(ws.receive(), timeout=5)
await ws.close()
await asyncio.sleep(0.1)
writer.close()
records = store.load_records(session_id)
assert len(records) == 2
http_rec = next(r for r in records if r.get("transport") != "websocket")
ws_rec = next(r for r in records if r.get("transport") == "websocket")
assert http_rec["request"]["method"] == "POST"
assert http_rec["response"]["status"] == 200
assert ws_rec["request"]["method"] == "WEBSOCKET"
assert ws_rec["response"]["status"] == 101
finally:
await proxy_session.close()
await proxy_runner.cleanup()
await upstream_runner.cleanup()
# ---------------------------------------------------------------------------
# Test 4: _get_ws_proxy_settings resolves proxy/auth from env
# ---------------------------------------------------------------------------
class TestGetWsProxySettings:
"""Unit tests for _get_ws_proxy_settings helper."""
@pytest.fixture(autouse=True)
def _clean_proxy_env(self, monkeypatch):
"""Remove all proxy env vars so tests control them explicitly."""
for var in (
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
):
monkeypatch.delenv(var, raising=False)
def test_wss_uses_https_proxy(self, monkeypatch):
monkeypatch.setenv("HTTPS_PROXY", "http://proxy:8080")
result = _get_ws_proxy_settings("wss://api.openai.com/v1/responses")
assert result == (URL("http://proxy:8080"), None)
def test_ws_uses_http_proxy(self, monkeypatch):
monkeypatch.setenv("HTTP_PROXY", "http://proxy:3128")
result = _get_ws_proxy_settings("ws://localhost/v1/responses")
assert result == (URL("http://proxy:3128"), None)
def test_proxy_auth_is_preserved(self, monkeypatch):
monkeypatch.setenv("HTTPS_PROXY", "http://user:pass@proxy:8080")
result = _get_ws_proxy_settings("wss://api.openai.com/v1/responses")
assert result is not None
proxy_url, proxy_auth = result
assert proxy_url == URL("http://proxy:8080")
assert proxy_auth is not None
assert proxy_auth.login == "user"
assert proxy_auth.password == "pass"
def test_no_proxy_returns_none(self, monkeypatch):
# Mock get_env_proxy_for_url to raise LookupError (no proxy configured).
# Necessary because macOS system proxy settings bypass env vars.
monkeypatch.setattr(
"claude_tap.ws_proxy.get_env_proxy_for_url",
lambda url: (_ for _ in ()).throw(LookupError("no proxy")),
)
result = _get_ws_proxy_settings("wss://api.openai.com/v1/responses")
assert result is None
def test_non_ws_scheme_returns_none(self):
result = _get_ws_proxy_settings("https://api.openai.com/v1/responses")
assert result is None