450 lines
17 KiB
Python
450 lines
17 KiB
Python
"""Responses WebSocket transport beneath the existing SDK and safety guards.
|
|
|
|
The SDK still serializes complete, authoritative requests. Only this wire boundary
|
|
omits a verified prefix. No model messages, tool results, or screenshots are lost
|
|
from local history, so any new connection can start from the full request.
|
|
"""
|
|
|
|
import asyncio
|
|
from collections import OrderedDict
|
|
from contextlib import suppress
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
from websockets.asyncio.client import connect
|
|
from websockets.exceptions import ConnectionClosed, InvalidStatus, SecurityError
|
|
from websockets.protocol import State
|
|
|
|
from artemis.llm.diagnostics import ACTIVE_ATTEMPT
|
|
from artemis.llm.http import model_http_options
|
|
from artemis.llm.responses_diagnostics import identifier
|
|
|
|
IDLE_SECONDS = 60
|
|
MAX_SESSIONS = 32
|
|
MAX_CONNECTION_SECONDS = 55 * 60
|
|
|
|
|
|
def _json(value):
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
|
|
|
|
def _fingerprint(item):
|
|
# Assistant message IDs/status are transport metadata; store=false strips
|
|
# message IDs on replay. Keep every semantic field, including call_id/phase.
|
|
if isinstance(item, dict):
|
|
item = dict(item)
|
|
item.pop("status", None)
|
|
if item.get("type") == "message" or item.get("role") == "assistant":
|
|
item.pop("id", None)
|
|
item.setdefault("type", "message")
|
|
# Completion events may include token probabilities; LangChain
|
|
# correctly omits those response-only statistics from replay input.
|
|
if isinstance(item.get("content"), list):
|
|
item["content"] = [
|
|
{k: v for k, v in block.items() if k != "logprobs"}
|
|
if isinstance(block, dict) and block.get("type") == "output_text"
|
|
else block
|
|
for block in item["content"]
|
|
]
|
|
if item.get("type") == "function_call" and isinstance(item.get("arguments"), str):
|
|
with suppress(ValueError):
|
|
item["arguments"] = json.loads(item["arguments"])
|
|
return hashlib.sha256(_json(item).encode()).digest()
|
|
|
|
|
|
class _Connect(connect):
|
|
def process_redirect(self, exc):
|
|
# Never forward the API credential to a redirected endpoint.
|
|
if not isinstance(exc, InvalidStatus) or exc.response.status_code not in {
|
|
301,
|
|
302,
|
|
303,
|
|
307,
|
|
308,
|
|
}:
|
|
return exc
|
|
return SecurityError("Responses WebSocket redirects are disabled")
|
|
|
|
|
|
class _Session:
|
|
def __init__(self):
|
|
self.lock = asyncio.Lock()
|
|
self.users = 0
|
|
self.connection = None
|
|
self.opened_at = 0.0
|
|
self.previous_id = None
|
|
self.history = ()
|
|
self.options = None
|
|
self.timer = None
|
|
self.handshake = {}
|
|
|
|
async def close(self):
|
|
if self.timer:
|
|
self.timer.cancel()
|
|
self.timer = None
|
|
self.previous_id = None
|
|
self.history = ()
|
|
self.options = None
|
|
connection, self.connection = self.connection, None
|
|
if connection is not None:
|
|
with suppress(ConnectionClosed, OSError, TimeoutError):
|
|
async with asyncio.timeout(1):
|
|
await connection.close()
|
|
|
|
|
|
class ResponsesWebSocketTransport(httpx.AsyncBaseTransport):
|
|
"""One serial socket per conversation/role, independent sockets for concurrency."""
|
|
|
|
def __init__(self, *, timeout, use_proxy):
|
|
self.timeout = timeout
|
|
self.use_proxy = use_proxy
|
|
self.http = httpx.AsyncClient(timeout=timeout, **model_http_options())
|
|
self.sessions = OrderedDict()
|
|
self.cleanup_tasks = set()
|
|
|
|
async def aclose(self):
|
|
sessions = list(self.sessions.values())
|
|
self.sessions.clear()
|
|
await asyncio.gather(*(session.close() for session in sessions))
|
|
if self.cleanup_tasks:
|
|
await asyncio.gather(*tuple(self.cleanup_tasks), return_exceptions=True)
|
|
await self.http.aclose()
|
|
|
|
def _close_background(self, session):
|
|
task = asyncio.create_task(session.close())
|
|
self.cleanup_tasks.add(task)
|
|
task.add_done_callback(self.cleanup_tasks.discard)
|
|
|
|
async def _session(self, request, body):
|
|
key = body.get("prompt_cache_key")
|
|
# Header identity prevents credentials, routing, and different gateways
|
|
# from sharing a socket. Per-request correlation headers aren't affinity.
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers.items()
|
|
if k not in {"content-length", "x-client-request-id", "x-client-attempt-id"}
|
|
}
|
|
identity = hashlib.sha256(_json([str(request.url), key, headers]).encode()).digest()
|
|
reusable = isinstance(key, str) and bool(key)
|
|
if reusable and identity in self.sessions:
|
|
session = self.sessions.pop(identity)
|
|
self.sessions[identity] = session
|
|
else:
|
|
if len(self.sessions) >= MAX_SESSIONS:
|
|
for old_key, old in list(self.sessions.items()):
|
|
if not old.users:
|
|
del self.sessions[old_key]
|
|
# Reserve the replacement before yielding: concurrent
|
|
# requests for its scope must find the same session.
|
|
self._close_background(old)
|
|
break
|
|
session = _Session()
|
|
reusable = reusable and len(self.sessions) < MAX_SESSIONS
|
|
if reusable:
|
|
self.sessions[identity] = session
|
|
acquired = False
|
|
session.users += 1
|
|
try:
|
|
await session.lock.acquire()
|
|
acquired = True
|
|
finally:
|
|
if not acquired:
|
|
session.users -= 1
|
|
self._idle(identity, session, reusable)
|
|
if session.timer:
|
|
session.timer.cancel()
|
|
session.timer = None
|
|
return identity, session, reusable
|
|
|
|
def release(self, identity, session, reusable):
|
|
session.lock.release()
|
|
session.users -= 1
|
|
self._idle(identity, session, reusable)
|
|
|
|
def _idle(self, identity, session, reusable):
|
|
if not reusable or session.users or self.sessions.get(identity) is not session:
|
|
return
|
|
|
|
def expire():
|
|
if session.users or self.sessions.get(identity) is not session:
|
|
return
|
|
del self.sessions[identity]
|
|
self._close_background(session)
|
|
|
|
session.timer = asyncio.get_running_loop().call_later(IDLE_SECONDS, expire)
|
|
|
|
async def handle_async_request(self, request):
|
|
if request.method != "POST" or not request.url.path.rstrip("/").endswith("/responses"):
|
|
return await self.http.send(request, stream=True)
|
|
body = json.loads(await request.aread())
|
|
identity, session, reusable = await self._session(request, body)
|
|
exchange = _Exchange(self, request, body, identity, session, reusable)
|
|
stream_handed_off = False
|
|
try:
|
|
await exchange.start()
|
|
first = await exchange.read()
|
|
if first.get("type") == "error":
|
|
status = first.get("status", 400)
|
|
status = status if type(status) is int and 400 <= status <= 599 else 400
|
|
return httpx.Response(status, json={"error": first.get("error", {})})
|
|
if not body.get("stream"):
|
|
event = first
|
|
while not exchange.done:
|
|
event = await exchange.read()
|
|
if event.get("type") == "error":
|
|
raise httpx.ReadError("Responses WebSocket request failed", request=request)
|
|
return httpx.Response(
|
|
200, json=event["response"], extensions={"artemis_websocket": True}
|
|
)
|
|
response = httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
stream=_EventStream(exchange, first),
|
|
extensions={"artemis_websocket": True},
|
|
)
|
|
stream_handed_off = True
|
|
return response
|
|
except InvalidStatus as error:
|
|
return httpx.Response(
|
|
error.response.status_code,
|
|
json={
|
|
"error": {
|
|
"message": "Responses WebSocket 升级失败,请检查网关 WebSocket 支持或选择 HTTP。"
|
|
}
|
|
},
|
|
headers={
|
|
k: v
|
|
for k, v in error.response.headers.raw_items()
|
|
if k.lower() in {"retry-after", "x-request-id"}
|
|
},
|
|
)
|
|
finally:
|
|
if not stream_handed_off:
|
|
await exchange.aclose()
|
|
|
|
|
|
class _Exchange:
|
|
def __init__(self, transport, request, body, identity, session, reusable):
|
|
self.transport = transport
|
|
self.request = request
|
|
self.body = body
|
|
self.identity = identity
|
|
self.session = session
|
|
self.reusable = reusable
|
|
self.done = False
|
|
self.closed = False
|
|
self.recovered = False
|
|
self.output_seen = False
|
|
self.input = body.get("input")
|
|
self.input_hashes = (
|
|
tuple(map(_fingerprint, self.input)) if isinstance(self.input, list) else ()
|
|
)
|
|
self.options = hashlib.sha256(
|
|
_json(
|
|
{
|
|
k: v
|
|
for k, v in body.items()
|
|
if k not in {"input", "stream", "background", "previous_response_id"}
|
|
}
|
|
).encode()
|
|
).digest()
|
|
self.explicit_state = bool(body.get("previous_response_id") or body.get("conversation"))
|
|
self.attempt = ACTIVE_ATTEMPT.get()
|
|
self.stats = {
|
|
"mode": "websocket",
|
|
"full_request_bytes": len(request.content),
|
|
"sent_payload_bytes": 0,
|
|
"received_payload_bytes": 0,
|
|
"create_events": 0,
|
|
"incremental": False,
|
|
"reused_input_items": 0,
|
|
"connection_reused": session.connection is not None,
|
|
}
|
|
if self.attempt is not None:
|
|
self.attempt.data["transport"] = self.stats
|
|
|
|
async def start(self):
|
|
session = self.session
|
|
if session.connection is not None and (
|
|
session.connection.state is not State.OPEN
|
|
or time.monotonic() - session.opened_at >= MAX_CONNECTION_SECONDS
|
|
):
|
|
await session.close()
|
|
self.stats["connection_reused"] = False
|
|
if session.connection is None:
|
|
url = self.request.url.copy_with(
|
|
scheme="wss" if self.request.url.scheme == "https" else "ws"
|
|
)
|
|
headers = {
|
|
k: v
|
|
for k, v in self.request.headers.items()
|
|
if k
|
|
not in {"host", "content-length", "content-type", "connection", "accept-encoding"}
|
|
}
|
|
options = {"ssl": model_http_options()["verify"]} if url.scheme == "wss" else {}
|
|
session.connection = await _Connect(
|
|
str(url),
|
|
additional_headers=headers,
|
|
user_agent_header=None,
|
|
proxy=True if self.transport.use_proxy else None,
|
|
open_timeout=self.transport.timeout,
|
|
close_timeout=1,
|
|
max_size=64 * 1024 * 1024,
|
|
**options,
|
|
)
|
|
session.opened_at = time.monotonic()
|
|
session.handshake = {
|
|
"client_request_id": identifier(headers.get("x-client-request-id")),
|
|
"client_attempt_id": identifier(headers.get("x-client-attempt-id")),
|
|
}
|
|
if self.attempt:
|
|
self.attempt.data.update(session.handshake, request_header_scope="websocket_handshake")
|
|
for field, local in (
|
|
("client_request_id", "request_id"),
|
|
("client_attempt_id", "attempt_id"),
|
|
):
|
|
self.attempt.data[field + "_matches"] = session.handshake.get(
|
|
field
|
|
) == self.attempt.data.get(local)
|
|
self.stats["handshake_http_status"] = 101
|
|
await self.send(incremental=True)
|
|
|
|
async def send(self, *, incremental):
|
|
session = self.session
|
|
payload = {k: v for k, v in self.body.items() if k not in {"stream", "background"}}
|
|
count = len(session.history)
|
|
can_continue = (
|
|
incremental
|
|
and not self.explicit_state
|
|
and session.previous_id
|
|
and session.options == self.options
|
|
and count
|
|
and len(self.input_hashes) > count
|
|
and self.input_hashes[:count] == session.history
|
|
)
|
|
if can_continue:
|
|
payload["previous_response_id"] = session.previous_id
|
|
payload["input"] = self.input[count:]
|
|
self.stats.update(incremental=True, reused_input_items=count)
|
|
else:
|
|
self.stats.update(incremental=False, reused_input_items=0)
|
|
self.stats["full_context_reason"] = (
|
|
"explicit_state"
|
|
if self.explicit_state
|
|
else "history_or_options_changed"
|
|
if session.previous_id
|
|
else "new_connection_or_chain"
|
|
)
|
|
payload["type"] = "response.create"
|
|
encoded = _json(payload)
|
|
await session.connection.send(encoded)
|
|
self.stats["sent_payload_bytes"] += len(encoded.encode())
|
|
self.stats["create_events"] += 1
|
|
self.stats["payload_bytes_saved"] = max(
|
|
0, self.stats["full_request_bytes"] - self.stats["sent_payload_bytes"]
|
|
)
|
|
if self.attempt:
|
|
self.attempt.data["request_sent_at"] = time.time()
|
|
self.attempt.publish_if_due()
|
|
|
|
async def read(self):
|
|
try:
|
|
async with asyncio.timeout(self.transport.timeout):
|
|
raw = await self.session.connection.recv()
|
|
self.stats["received_payload_bytes"] += len(
|
|
raw.encode() if isinstance(raw, str) else raw
|
|
)
|
|
event = json.loads(raw)
|
|
if not isinstance(event, dict):
|
|
raise ValueError("Responses WebSocket event is not an object")
|
|
except ConnectionClosed as error:
|
|
raise httpx.ReadError(
|
|
"Responses WebSocket connection closed before completion", request=self.request
|
|
) from error
|
|
except (ValueError, UnicodeError) as error:
|
|
raise httpx.ReadError(
|
|
"Invalid Responses WebSocket event", request=self.request
|
|
) from error
|
|
kind = event.get("type", "")
|
|
error = event.get("error") or {}
|
|
if (
|
|
kind == "error"
|
|
and error.get("code") == "previous_response_not_found"
|
|
and self.stats["incremental"]
|
|
and not self.recovered
|
|
and not self.output_seen
|
|
):
|
|
# An explicit rejection means no model output was generated. Recover
|
|
# once from the authoritative input; never replay an ambiguous drop.
|
|
self.recovered = True
|
|
self.session.previous_id = None
|
|
self.stats["chain_recovered"] = True
|
|
await self.send(incremental=False)
|
|
return await self.read()
|
|
if kind not in {"error", "response.created", "response.in_progress", "response.queued"}:
|
|
self.output_seen = True
|
|
if kind in {
|
|
"response.completed",
|
|
"response.failed",
|
|
"response.incomplete",
|
|
"response.cancelled",
|
|
}:
|
|
self.done = True
|
|
response = event.get("response") or {}
|
|
self.session.previous_id = None
|
|
if (
|
|
kind == "response.completed"
|
|
and response.get("status") == "completed"
|
|
and isinstance(response.get("id"), str)
|
|
and response["id"]
|
|
and isinstance(response.get("output"), list)
|
|
and self.input_hashes
|
|
and not self.explicit_state
|
|
):
|
|
output = [
|
|
item
|
|
for item in response["output"]
|
|
if not (
|
|
item.get("type") == "reasoning"
|
|
and not item.get("encrypted_content")
|
|
and self.body.get("store") is False
|
|
)
|
|
]
|
|
self.session.previous_id = response["id"]
|
|
self.session.history = self.input_hashes + tuple(map(_fingerprint, output))
|
|
self.session.options = self.options
|
|
return event
|
|
|
|
async def aclose(self):
|
|
if self.closed:
|
|
return
|
|
self.closed = True
|
|
try:
|
|
if not self.done or not self.reusable:
|
|
await self.session.close()
|
|
finally:
|
|
self.transport.release(self.identity, self.session, self.reusable)
|
|
|
|
|
|
class _EventStream(httpx.AsyncByteStream):
|
|
def __init__(self, exchange, first):
|
|
self.exchange = exchange
|
|
self.first = first
|
|
|
|
async def __aiter__(self):
|
|
try:
|
|
event = self.first
|
|
while True:
|
|
yield b"data: " + _json(event).encode() + b"\n\n"
|
|
if self.exchange.done or event.get("type") == "error":
|
|
return
|
|
event = await self.exchange.read()
|
|
finally:
|
|
await self.aclose()
|
|
|
|
async def aclose(self):
|
|
await self.exchange.aclose()
|