verifiers 0.3.2.dev131__py3-none-any.whl → 0.3.2.dev133__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -512,6 +512,14 @@ class AnthropicDialect(Dialect[AnthropicMessage]):
512
512
  "error": {"type": "invalid_request_error", "message": message},
513
513
  }
514
514
 
515
+ def stream_keepalive(self, first: bool) -> bytes:
516
+ # Anthropic's own keepalive; its SDKs skip it anywhere in the stream.
517
+ return b'event: ping\ndata: {"type": "ping"}\n\n'
518
+
519
+ def stream_error(self, error: dict) -> bytes:
520
+ # The Anthropic SDKs raise only on a named `error` event.
521
+ return b"event: error\ndata: " + json.dumps(error).encode() + b"\n\n"
522
+
515
523
  def parse_request(self, body: RawRequest) -> Request:
516
524
  native_tools = body.get("tools") or []
517
525
  if not isinstance(native_tools, list) or any(
@@ -317,6 +317,18 @@ class Dialect(ABC, Generic[RespT]):
317
317
  """An error payload in this format's error shape (OpenAI by default)."""
318
318
  return {"error": {"message": message, "type": "invalid_request_error"}}
319
319
 
320
+ def stream_keepalive(self, first: bool) -> bytes:
321
+ """A keepalive for a committed SSE stream whose turn is still being produced (`first`
322
+ on the stream's first one). A comment line by default: these clients count any bytes
323
+ as activity. A dialect whose clients only count events sends a no-op event instead."""
324
+ # Don't terminate an empty event; some SSE clients try to JSON-decode it.
325
+ return b": keepalive\n"
326
+
327
+ def stream_error(self, error: dict) -> bytes:
328
+ """An `error_body` as an SSE event, for a failure after the stream is committed.
329
+ OpenAI SDKs raise on any event carrying `error`."""
330
+ return b"data: " + json.dumps(error).encode() + b"\n\n"
331
+
320
332
  @abstractmethod
321
333
  def mediate_external_capabilities(
322
334
  self, body: RawRequest, policy: NetworkPolicyConfig
@@ -49,6 +49,20 @@ from verifiers.v1.types import (
49
49
  UserMessage,
50
50
  )
51
51
 
52
+ # The placeholder response a committed stream's keepalives carry until its turn is ready:
53
+ # schema-valid, so strictly validating clients accept the events that carry it.
54
+ _KEEPALIVE_RESPONSE = {
55
+ "id": "resp_keepalive",
56
+ "object": "response",
57
+ "created_at": 0,
58
+ "model": "",
59
+ "status": "in_progress",
60
+ "output": [],
61
+ "parallel_tool_calls": True,
62
+ "tool_choice": "auto",
63
+ "tools": [],
64
+ }
65
+
52
66
  FINAL_EVENTS = ("response.completed", "response.incomplete", "response.failed")
53
67
  # Byte markers for the terminal event types above, in both compact and spaced JSON, so the
54
68
  # interception server can cheaply spot the turn-ending event without parsing each delta.
@@ -648,6 +662,33 @@ class ResponsesDialect(Dialect[OpenAIResponse]):
648
662
  if "output_text" in raw:
649
663
  raw["output_text"] = text
650
664
 
665
+ def stream_keepalive(self, first: bool) -> bytes:
666
+ # Codex's idle timer resets only on data events (it drops comments), and the OpenAI
667
+ # SDK's stream helper rejects any event before `response.created`: open with a
668
+ # placeholder `response.created`, then repeat `response.in_progress`. The turn's own
669
+ # `response.created` follows and supersedes it; clients take the turn's id and final
670
+ # state from its own events, and its sequence numbers restart at 0.
671
+ kind = "response.created" if first else "response.in_progress"
672
+ payload = {"type": kind, "sequence_number": 0, "response": _KEEPALIVE_RESPONSE}
673
+ return f"event: {kind}\ndata: {json.dumps(payload)}\n\n".encode()
674
+
675
+ def stream_error(self, error: dict) -> bytes:
676
+ # `response.failed` is what Responses clients (codex) act on; the `error` key is what
677
+ # the OpenAI SDK raises on.
678
+ message = error["error"]["message"]
679
+ failed = {
680
+ **_KEEPALIVE_RESPONSE,
681
+ "status": "failed",
682
+ "error": {"code": "server_error", "message": message},
683
+ }
684
+ payload = {
685
+ "type": "response.failed",
686
+ "sequence_number": 0,
687
+ "response": failed,
688
+ **error,
689
+ }
690
+ return f"event: response.failed\ndata: {json.dumps(payload)}\n\n".encode()
691
+
651
692
  def stream_events(self, raw: dict) -> list[bytes]:
652
693
  item = raw["output"][0]
653
694
  part = item["content"][0]
@@ -240,7 +240,9 @@ async def chat(
240
240
  *,
241
241
  tool_choice: str | None = None,
242
242
  ):
243
- kwargs = {"model": model, "messages": messages, "tools": tools or None}
243
+ kwargs = {"model": model, "messages": messages}
244
+ if tools:
245
+ kwargs["tools"] = tools
244
246
  if tools and tool_choice is not None:
245
247
  kwargs["tool_choice"] = tool_choice
246
248
  raw_stream = await client.chat.completions.create(
@@ -27,10 +27,9 @@ import logging
27
27
  import secrets
28
28
  import time
29
29
  import traceback
30
- from collections.abc import AsyncIterator, Collection, Mapping
30
+ from collections.abc import AsyncIterator, Awaitable, Collection, Mapping
31
31
  from contextlib import asynccontextmanager
32
32
  from dataclasses import dataclass
33
- from tempfile import SpooledTemporaryFile
34
33
  from typing import Literal
35
34
 
36
35
  import httpx
@@ -41,6 +40,7 @@ from pydantic_core import PydanticSerializationError, from_json, to_json
41
40
  from verifiers.v1 import graph
42
41
  from verifiers.v1.clients import Client, resolve_client
43
42
  from verifiers.v1.clients.base import join_url
43
+ from verifiers.v1.clients.client import RelayReply
44
44
  from verifiers.v1.configs.client import (
45
45
  BaseClientConfig,
46
46
  TrainClientConfig,
@@ -66,7 +66,7 @@ from verifiers.v1.interception.tunnel import (
66
66
  from verifiers.v1.semantic import ACPInfo, extract_acp_info
67
67
  from verifiers.v1.session import IdempotentRequest, ReplayResponse, RolloutSession
68
68
  from verifiers.v1.trace import Error, ModelCall, PolicyEvent, TimeSpan
69
- from verifiers.v1.types import FinishReason, Request, Response, Usage
69
+ from verifiers.v1.types import FinishReason, Response, Usage
70
70
 
71
71
  logger = logging.getLogger(__name__)
72
72
 
@@ -77,8 +77,10 @@ logger = logging.getLogger(__name__)
77
77
  # context window are the real limits, this is just a host-OOM backstop.
78
78
  MAX_REQUEST_BODY = 1024**3 # 1 GiB (aiohttp's default is 1 MiB)
79
79
  KEEPALIVE_INTERVAL_SECONDS = 3
80
- STREAM_QUEUE_MAXSIZE = 16
81
- STREAM_MEMORY_BUFFER = 4 * 1024**2
80
+ # A streamed turn commits its SSE stream only after this long: a result within it keeps
81
+ # its HTTP status (so the harness SDK can retry 5xx/429), and a longer one is kept alive
82
+ # well inside the tunnel's response-header timeout.
83
+ KEEPALIVE_GRACE_SECONDS = 60
82
84
  # blake2b saturates ~1.7 GB/s, so a body up to this size hashes inline in well under a
83
85
  # millisecond; a larger one (bodies may reach `MAX_REQUEST_BODY`) is hashed off the event
84
86
  # loop instead — see `_request_digest`.
@@ -204,18 +206,108 @@ def _prune_idempotent_requests(session: RolloutSession, now: float) -> None:
204
206
  session.idempotent_requests.pop(key)
205
207
 
206
208
 
207
- async def _queue_chunks(
208
- chunks: AsyncIterator[bytes],
209
- queue: asyncio.Queue[bytes | None],
210
- ready: asyncio.Event,
211
- ) -> None:
209
+ async def _collect_stream(
210
+ dialect: Dialect, reply: RelayReply
211
+ ) -> tuple[Response, bytes]:
212
+ """Read a relayed provider stream whole: the assembled response and the events to serve
213
+ for it. Comment-only events (the provider's own keepalives) are dropped; the served
214
+ stream sends its own."""
215
+ events = bytearray()
216
+ parser = dialect.stream_parser()
217
+ saw_terminal = False
212
218
  try:
213
- async for chunk in chunks:
214
- await queue.put(chunk)
215
- ready.set()
219
+ async for chunk in reply.chunks:
220
+ if not any(line.startswith(b"data:") for line in chunk.splitlines()):
221
+ continue
222
+ events += chunk
223
+ saw_terminal |= dialect.is_terminal_event(chunk)
224
+ if parser.on_done is not None and is_sse_done_event(chunk):
225
+ parser.on_done()
226
+ parser.feed(chunk)
227
+ if not saw_terminal:
228
+ raise ProviderError("upstream stream ended before its terminal event")
229
+ return parser.finish(), bytes(events)
230
+ except RolloutError:
231
+ raise
232
+ except Exception as e: # a malformed provider stream
233
+ raise ProviderError(str(e)) from e
216
234
  finally:
217
- await queue.put(None)
218
- ready.set()
235
+ await reply.close()
236
+
237
+
238
+ async def _buffered_stream(
239
+ request: web.Request,
240
+ dialect: Dialect,
241
+ pending: Awaitable[web.Response],
242
+ trace_id: str,
243
+ ) -> web.StreamResponse:
244
+ """Serve a turn to an SSE client once it is committed, keeping the connection alive
245
+ while it is produced. A result within the grace period is served as is; after it the
246
+ stream is committed and sent the dialect's keepalives, so neither a proxy's timeout
247
+ nor a client's own idle timeout cuts a long turn. Once committed, a failure is framed
248
+ as the dialect's SSE error. A reader that goes away leaves the turn running for its
249
+ retries to coalesce onto."""
250
+ task = asyncio.ensure_future(pending)
251
+ started = time.monotonic()
252
+ try:
253
+ done, _ = await asyncio.wait({task}, timeout=KEEPALIVE_GRACE_SECONDS)
254
+ if done:
255
+ return task.result()
256
+ stream = web.StreamResponse(
257
+ headers={
258
+ "Content-Type": "text/event-stream",
259
+ "Cache-Control": "no-cache",
260
+ "Connection": "keep-alive",
261
+ "X-Accel-Buffering": "no",
262
+ }
263
+ )
264
+ connected = True
265
+ try:
266
+ await stream.prepare(request)
267
+ first = True
268
+ while not task.done():
269
+ await stream.write(dialect.stream_keepalive(first))
270
+ first = False
271
+ await asyncio.wait({task}, timeout=KEEPALIVE_INTERVAL_SECONDS)
272
+ except ConnectionResetError:
273
+ # A reader that goes away mid-turn is the failure a tunnel or proxy drop looks
274
+ # like from here; its retry (if any) coalesces onto this turn.
275
+ connected = False
276
+ logger.warning(
277
+ "intercept stream: reader disconnected: id=%s after=%.1fs",
278
+ trace_id,
279
+ time.monotonic() - started,
280
+ )
281
+ response = await task
282
+ replay = _capture_response(response)
283
+ # Release coalesced retries now rather than after the write to this reader.
284
+ _finish_idempotent_attempt(request, replay)
285
+ if connected:
286
+ try:
287
+ await stream.write(
288
+ replay.body
289
+ if response.status < 400
290
+ else dialect.stream_error(from_json(replay.body))
291
+ )
292
+ await stream.write_eof()
293
+ except ConnectionResetError:
294
+ logger.warning(
295
+ "intercept stream: reader disconnected before the turn was served: "
296
+ "id=%s after=%.1fs",
297
+ trace_id,
298
+ time.monotonic() - started,
299
+ )
300
+ return stream
301
+ finally:
302
+ if not task.done():
303
+ logger.info(
304
+ "intercept stream: turn cancelled: id=%s after=%.1fs",
305
+ trace_id,
306
+ time.monotonic() - started,
307
+ )
308
+ task.cancel()
309
+ with contextlib.suppress(asyncio.CancelledError):
310
+ await task
219
311
 
220
312
 
221
313
  class InterceptionServerConfig(BaseInterceptionConfig):
@@ -503,10 +595,10 @@ class InterceptionServer(Interception):
503
595
  body = json.loads(raw)
504
596
  body = dialect.apply_overrides(body, session.ctx.model, session.ctx.sampling)
505
597
  streaming = dialect.streaming(body)
506
- relay_streaming = streaming and not isinstance(
507
- session.ctx.client, TrainClientConfig
508
- )
509
- req_hash = await _request_digest(raw) if not relay_streaming else b""
598
+ # A streamed request is served whole once its turn commits: the eval client's
599
+ # provider stream is read to the end, and the train client generates the response.
600
+ relay = streaming and not isinstance(session.ctx.client, TrainClientConfig)
601
+ req_hash = await _request_digest(raw)
510
602
  # Keep `read()` for aiohttp's size guard, then release its cache and our local
511
603
  # alias after parsing so the wire body does not survive model inference.
512
604
  request._read_bytes = None
@@ -516,10 +608,11 @@ class InterceptionServer(Interception):
516
608
  except ValueError as error:
517
609
  return web.json_response(dialect.error_body(str(error)), status=400)
518
610
  logger.debug(
519
- "intercept %s: id=%s stream=%s",
611
+ "intercept %s: id=%s stream=%s retry=%s",
520
612
  request.path,
521
613
  session.trace.id,
522
614
  streaming,
615
+ request.headers.get(RETRY_COUNT_HEADER, "0"),
523
616
  )
524
617
  # Graph atomicity under retries: one logical buffered call must commit at most
525
618
  # one turn. An explicit key identifies that call directly; otherwise only the SDK's
@@ -528,18 +621,9 @@ class InterceptionServer(Interception):
528
621
  retried = is_retried_request(request.headers)
529
622
  idempotent: IdempotentRequest | None = None
530
623
  idempotency_key = request.headers.get(IDEMPOTENCY_KEY_HEADER)
531
- replay_key: str | None = None
532
624
  binding = (request.path, req_hash)
533
625
  if idempotency_key:
534
- if relay_streaming and acp is None:
535
- return web.json_response(
536
- dialect.error_body(
537
- "Idempotency-Key is not supported for streaming requests"
538
- ),
539
- status=400,
540
- )
541
- if not relay_streaming:
542
- replay_key = f"explicit:{idempotency_key}"
626
+ replay_key = f"explicit:{idempotency_key}"
543
627
  # This key identifies the harness-to-interception hop. The server owns its
544
628
  # replay semantics, and the body has since been rewritten with rollout model
545
629
  # and sampling overrides, so never expose the local key to the provider.
@@ -548,27 +632,26 @@ class InterceptionServer(Interception):
548
632
  for name, value in upstream_headers.items()
549
633
  if name.lower() != IDEMPOTENCY_KEY_HEADER.lower()
550
634
  }
551
- elif not relay_streaming:
635
+ else:
552
636
  replay_key = f"retry:{request.path}:{req_hash.hex()}"
553
637
 
554
- if replay_key is not None:
555
- now = time.monotonic()
556
- _prune_idempotent_requests(session, now)
557
- if idempotency_key or retried:
558
- idempotent = session.idempotent_requests.get(replay_key)
559
- if idempotent is not None and idempotent.binding != binding:
560
- return web.json_response(
561
- dialect.error_body(
562
- "Idempotency-Key was reused with a different request"
563
- ),
564
- status=400,
565
- )
566
- if idempotent is not None and idempotent.response is not None:
567
- logger.debug(
568
- "intercept replay: id=%s (idempotent request)", session.trace.id
569
- )
570
- idempotent.completed_at = now
571
- return _replay_response(idempotent.response)
638
+ now = time.monotonic()
639
+ _prune_idempotent_requests(session, now)
640
+ if idempotency_key or retried:
641
+ idempotent = session.idempotent_requests.get(replay_key)
642
+ if idempotent is not None and idempotent.binding != binding:
643
+ return web.json_response(
644
+ dialect.error_body(
645
+ "Idempotency-Key was reused with a different request"
646
+ ),
647
+ status=400,
648
+ )
649
+ if idempotent is not None and idempotent.response is not None:
650
+ logger.debug(
651
+ "intercept replay: id=%s (idempotent request)", session.trace.id
652
+ )
653
+ idempotent.completed_at = now
654
+ return _replay_response(idempotent.response)
572
655
 
573
656
  try:
574
657
  model_request = dialect.parse_request(body)
@@ -583,7 +666,7 @@ class InterceptionServer(Interception):
583
666
  dialect.error_body(f"rollout stopped: {session.trace.stop_condition}"),
584
667
  status=400,
585
668
  )
586
- if replay_key is not None and idempotent is None:
669
+ if idempotent is None:
587
670
  idempotent = IdempotentRequest(binding=binding)
588
671
  session.idempotent_requests[replay_key] = idempotent
589
672
 
@@ -600,20 +683,22 @@ class InterceptionServer(Interception):
600
683
  )
601
684
  return _replay_response(response)
602
685
 
603
- if idempotent is not None:
604
- if idempotent.inflight is not None:
605
- return await coalesced(idempotent.inflight)
606
- assert replay_key is not None
607
- future: asyncio.Future[ReplayResponse | None] = (
608
- asyncio.get_running_loop().create_future()
609
- )
610
- idempotent.inflight = future
611
- request[_IDEMPOTENT_ATTEMPT] = _IdempotentAttempt(
612
- session=session,
613
- key=replay_key,
614
- request=idempotent,
615
- future=future,
616
- )
686
+ if idempotent.inflight is not None:
687
+ if streaming:
688
+ return await _buffered_stream(
689
+ request, dialect, coalesced(idempotent.inflight), session.trace.id
690
+ )
691
+ return await coalesced(idempotent.inflight)
692
+ future: asyncio.Future[ReplayResponse | None] = (
693
+ asyncio.get_running_loop().create_future()
694
+ )
695
+ idempotent.inflight = future
696
+ request[_IDEMPOTENT_ATTEMPT] = _IdempotentAttempt(
697
+ session=session,
698
+ key=replay_key,
699
+ request=idempotent,
700
+ future=future,
701
+ )
617
702
 
618
703
  try:
619
704
  refused = await session.refused()
@@ -668,46 +753,27 @@ class InterceptionServer(Interception):
668
753
  # turns): live watchers see it now rather than with the model's reply.
669
754
  session.trace.preview(turn, turn.tail)
670
755
 
671
- # Request hooks police the proposed tool calls at commit, before the response is
672
- # released, so a stream is buffered for them too.
673
- inspect_response = bool(
674
- session.response_interceptors
675
- or session.response_stops
676
- or session.request_interceptors
677
- or session.request_stops
678
- )
679
- if relay_streaming:
680
- return await self._stream(
681
- request,
682
- session,
683
- dialect,
684
- body,
685
- model_request,
686
- turn=turn,
687
- inspect_response=inspect_response,
688
- policy_paths=policy_paths,
689
- acp=acp,
690
- upstream_headers=upstream_headers,
691
- )
692
-
693
- def serve(response: Response) -> web.Response:
756
+ def serve(response: Response, events: bytes | None) -> web.Response:
694
757
  if streaming:
695
- # Training generates a complete response with token metadata.
696
- # Commit it through the normal path, then frame it for SSE clients.
758
+ # The committed turn as SSE: the provider's own events when relayed
759
+ # unchanged, else framed from the response (training generates it whole,
760
+ # and a hook may have rewritten a relayed one).
697
761
  served = web.Response(
698
- body=b"".join(dialect.stream_events(response.raw or {})),
762
+ body=events
763
+ if events is not None
764
+ else b"".join(dialect.stream_events(response.raw or {})),
699
765
  content_type="text/event-stream",
700
766
  )
701
767
  else:
702
768
  served = _completion_response(response.raw)
703
- if idempotent is not None:
704
- idempotent.response = _capture_response(served)
705
- idempotent.completed_at = time.monotonic()
769
+ idempotent.response = _capture_response(served)
770
+ idempotent.completed_at = time.monotonic()
706
771
  return served
707
772
 
708
773
  async def sample() -> web.Response:
709
774
  session.error = None
710
775
  call_response: Response | None = None
776
+ events: bytes | None = None
711
777
  node: int | None = None
712
778
  error: Exception | None = None
713
779
  started = time.time()
@@ -715,14 +781,23 @@ class InterceptionServer(Interception):
715
781
  try:
716
782
  # What actually goes upstream: the native body with the rollout's model +
717
783
  # sampling imposed — recorded raw on the trace, per call.
718
- call_response = await session.client.get_response(
719
- dialect,
720
- body,
721
- session.ctx.sampling,
722
- headers=upstream_headers,
723
- session_id=session.trace.id,
724
- turn=turn,
725
- )
784
+ if relay:
785
+ reply = await session.client.relay(
786
+ dialect,
787
+ body,
788
+ headers=upstream_headers,
789
+ session_id=session.trace.id,
790
+ )
791
+ call_response, events = await _collect_stream(dialect, reply)
792
+ else:
793
+ call_response = await session.client.get_response(
794
+ dialect,
795
+ body,
796
+ session.ctx.sampling,
797
+ headers=upstream_headers,
798
+ session_id=session.trace.id,
799
+ turn=turn,
800
+ )
726
801
  logger.debug(
727
802
  "intercept turn: id=%s tools=%d",
728
803
  session.trace.id,
@@ -741,6 +816,7 @@ class InterceptionServer(Interception):
741
816
  stopped,
742
817
  ) = await session.rewrite_response(call_response)
743
818
  if response_rewrites:
819
+ events = None
744
820
  assert call_response.raw is not None
745
821
  dialect.rewrite_response(
746
822
  call_response.raw, call_response.message.content or ""
@@ -816,279 +892,12 @@ class InterceptionServer(Interception):
816
892
  policy_paths=policy_paths,
817
893
  acp=acp,
818
894
  )
819
- return serve(call_response)
895
+ return serve(call_response, events)
820
896
 
897
+ if streaming:
898
+ return await _buffered_stream(request, dialect, sample(), session.trace.id)
821
899
  return await sample()
822
900
 
823
- async def _stream(
824
- self,
825
- request: web.Request,
826
- session: RolloutSession,
827
- dialect: Dialect,
828
- body: dict,
829
- model_request: Request,
830
- *,
831
- turn: graph.PendingTurn,
832
- inspect_response: bool,
833
- policy_paths: list[str] | None = None,
834
- acp: ACPInfo | None = None,
835
- upstream_headers: Mapping[str, str] | None = None,
836
- ) -> web.StreamResponse:
837
- """A streamed (SSE) model turn: relay the provider's stream through to the program,
838
- incrementally assembling the response to record on the trace (the only client that
839
- streams is the eval relay)."""
840
- session.error = None
841
- reply = None
842
- response: Response | None = None
843
- node: int | None = None
844
- error: Exception | None = None
845
- started = time.time()
846
- try:
847
- try:
848
- reply = await session.client.relay(
849
- dialect,
850
- body,
851
- headers=upstream_headers,
852
- session_id=session.trace.id,
853
- )
854
- except RolloutError as e:
855
- error = e
856
- session.error = e
857
- logger.warning(
858
- "model call failed: id=%s %s: %s",
859
- session.trace.id,
860
- type(e).__name__,
861
- e,
862
- )
863
- return web.json_response(
864
- dialect.error_body(str(e)), status=getattr(e, "status_code", 502)
865
- )
866
- except Exception as e: # noqa: BLE001 - surface as an API error
867
- error = e
868
- logger.warning("model call failed: id=%s %s", session.trace.id, e)
869
- return web.json_response(dialect.error_body(str(e)), status=502)
870
-
871
- if inspect_response:
872
- buffered = SpooledTemporaryFile( # noqa: SIM115 - closed before every exit
873
- max_size=STREAM_MEMORY_BUFFER
874
- )
875
- parser = dialect.stream_parser()
876
- saw_terminal = False
877
- try:
878
- async for chunk in reply.chunks:
879
- buffered.write(chunk)
880
- saw_terminal |= dialect.is_terminal_event(chunk)
881
- if parser.on_done is not None and is_sse_done_event(chunk):
882
- parser.on_done()
883
- parser.feed(chunk)
884
- if not saw_terminal:
885
- raise ProviderError(
886
- "upstream stream ended before its terminal event"
887
- )
888
- response = parser.finish()
889
- response_rewrites = []
890
- stopped = None
891
- if session.response_interceptors or session.response_stops:
892
- (
893
- response,
894
- response_rewrites,
895
- stopped,
896
- ) = await session.rewrite_response(response)
897
- if response_rewrites:
898
- assert response.raw is not None
899
- dialect.rewrite_response(
900
- response.raw, response.message.content or ""
901
- )
902
- raw_response = response.raw
903
- response = dialect.parse_response(
904
- dialect.validate_response(raw_response)
905
- )
906
- response.raw = raw_response
907
- except RolloutError as e:
908
- buffered.close()
909
- error = e
910
- session.error = e
911
- return self._fail(session, dialect, e)
912
- except Exception as e: # noqa: BLE001 - malformed provider stream
913
- buffered.close()
914
- error = ProviderError(str(e))
915
- session.error = error
916
- return self._fail(session, dialect, error)
917
- finally:
918
- await reply.close()
919
-
920
- if session.released or session.stopped:
921
- buffered.close()
922
- return web.json_response(
923
- dialect.error_body(
924
- "rollout concluded"
925
- if session.released
926
- else f"rollout stopped: {session.trace.stop_condition}"
927
- ),
928
- status=409 if session.released else 400,
929
- )
930
- node = turn.commit(response)
931
- session.consume_prepared(turn.tail)
932
- session.trace.response_rewrites.extend(response_rewrites)
933
- if stopped is None:
934
- try:
935
- stopped = await session.gate_tool_calls(node)
936
- except RolloutError as e:
937
- error = e
938
- buffered.close()
939
- return self._fail(session, dialect, e)
940
- if stopped is not None:
941
- buffered.close()
942
- session.trace.stop(stopped)
943
- return web.json_response(
944
- dialect.error_body(f"rollout stopped: {stopped}"),
945
- status=400,
946
- )
947
-
948
- resp = web.StreamResponse(
949
- headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
950
- )
951
- resp.content_type = reply.content_type.split(";")[0].strip()
952
- try:
953
- await resp.prepare(request)
954
- if response_rewrites:
955
- for event in dialect.stream_events(response.raw or {}):
956
- await resp.write(event)
957
- else:
958
- buffered.seek(0)
959
- while chunk := buffered.read(64 * 1024):
960
- await resp.write(chunk)
961
- await resp.write_eof()
962
- except ConnectionResetError:
963
- pass
964
- finally:
965
- buffered.close()
966
- return resp
967
-
968
- resp = web.StreamResponse(
969
- headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
970
- )
971
- resp.content_type = reply.content_type.split(";")[0].strip()
972
- # Parse complete events as they relay, avoiding a full-stream byte copy.
973
- parser = dialect.stream_parser()
974
- feed_event = parser.feed
975
- on_done = parser.on_done
976
- # One bounded producer avoids per-event tasks; keepalive timeouts only cancel readiness waits.
977
- queue: asyncio.Queue[bytes | None] = asyncio.Queue(
978
- maxsize=STREAM_QUEUE_MAXSIZE
979
- )
980
- ready = asyncio.Event()
981
- producer = asyncio.create_task(_queue_chunks(reply.chunks, queue, ready))
982
- parser_error: Exception | None = None
983
- # SSE events from the turn-ending one onward (the terminal event and any trailing
984
- # `[DONE]`), withheld until the turn is committed: a client that ends its turn on the
985
- # terminal event (e.g. codex on `response.completed`) would otherwise reach scoring
986
- # with the turn still unrecorded.
987
- deferred: list[bytes] = []
988
- try:
989
- await resp.prepare(request)
990
- while True:
991
- try:
992
- async with asyncio.timeout(KEEPALIVE_INTERVAL_SECONDS):
993
- await ready.wait()
994
- except TimeoutError:
995
- # Don't terminate an empty event; some SSE clients try to JSON-decode it.
996
- await resp.write(b": keepalive\n")
997
- continue
998
- chunk = queue.get_nowait()
999
- if queue.empty():
1000
- ready.clear()
1001
- if chunk is None:
1002
- await producer
1003
- break
1004
- # We send our own keepalive above. Some clients treat a complete
1005
- # comment-only event from upstream as an empty JSON payload.
1006
- if not any(
1007
- line.startswith(b"data:") for line in chunk.splitlines()
1008
- ):
1009
- await resp.write(b": keepalive\n")
1010
- continue
1011
- if deferred or dialect.is_terminal_event(chunk):
1012
- if parser_error is None:
1013
- try:
1014
- if on_done is not None and is_sse_done_event(chunk):
1015
- on_done()
1016
- feed_event(chunk)
1017
- except Exception as e: # noqa: BLE001 - defer parser failure
1018
- parser_error = e
1019
- # forwarded after the turn is committed, below
1020
- deferred.append(chunk)
1021
- continue
1022
- await resp.write(chunk)
1023
- if parser_error is None:
1024
- try:
1025
- feed_event(chunk)
1026
- except Exception as e: # noqa: BLE001 - defer parser failure
1027
- parser_error = e
1028
- except ConnectionResetError as e:
1029
- # The harness went away mid-stream; the provider exchange still happened.
1030
- error = e
1031
- return resp
1032
- finally:
1033
- producer.cancel()
1034
- # Let a canceled producer enqueue EOF while unwinding.
1035
- if queue.full():
1036
- queue.get_nowait()
1037
- await asyncio.gather(producer, return_exceptions=True)
1038
- await reply.close()
1039
-
1040
- try:
1041
- if parser_error is not None:
1042
- raise parser_error
1043
- response = parser.finish()
1044
- if not session.released and not session.stopped:
1045
- node = turn.commit(response)
1046
- session.consume_prepared(turn.tail)
1047
- logger.debug("intercept stream turn: id=%s", session.trace.id)
1048
- elif session.stopped:
1049
- with contextlib.suppress(ConnectionResetError):
1050
- await resp.write_eof()
1051
- return resp
1052
- finally:
1053
- # Release the withheld events only now — after the commit — then close.
1054
- with contextlib.suppress(ConnectionResetError):
1055
- for event in deferred:
1056
- await resp.write(event)
1057
- await resp.write_eof()
1058
- return resp
1059
- except RolloutError as e:
1060
- # A streamed terminal provider failure is discovered only after the
1061
- # response body has been relayed. Keep it off the graph and preserve
1062
- # the typed cause for the rollout if the native SDK does not retry it.
1063
- if node is None:
1064
- error = e
1065
- session.error = e
1066
- raise
1067
- except BaseException as e:
1068
- # Anything that propagates (a mid-relay upstream failure, a parser or commit
1069
- # error, a cancellation) ends a real exchange; couple it to the record unless
1070
- # the turn already committed (then only post-commit delivery failed).
1071
- if node is None:
1072
- error = e
1073
- raise
1074
- finally:
1075
- if node is None:
1076
- turn.abandon()
1077
- # The turn's one per-exchange record: settings, timing, outcome, and the
1078
- # error that ended it (if any).
1079
- self.record_call(
1080
- session,
1081
- dialect,
1082
- body,
1083
- started,
1084
- node=node,
1085
- finish_reason=response.finish_reason if response is not None else None,
1086
- usage=response.usage if response is not None else None,
1087
- error=error,
1088
- policy_paths=policy_paths,
1089
- acp=acp,
1090
- )
1091
-
1092
901
  async def handle_aux(
1093
902
  self, request: web.Request, dialect: Dialect, route: str
1094
903
  ) -> web.Response:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifiers
3
- Version: 0.3.2.dev131
3
+ Version: 0.3.2.dev133
4
4
  Summary: Verifiers: Environments for LLM Reinforcement Learning
5
5
  Project-URL: Homepage, https://github.com/primeintellect-ai/verifiers
6
6
  Project-URL: Documentation, https://github.com/primeintellect-ai/verifiers
@@ -60,10 +60,10 @@ verifiers/v1/configs/cli/init.py,sha256=JvIUOdhfZfNOXg2wm2i06jLLHpb97tNMzZqmcK_9
60
60
  verifiers/v1/configs/cli/replay.py,sha256=vUCNs-9j1CXcAZhGozN1EaztcXK_dYVK3iN_fRmbSXw,2929
61
61
  verifiers/v1/configs/cli/validate.py,sha256=z7iueq4ndDFKOQ1GA2vaK3C8RhxhDUim-Nn-71T3kGw,3694
62
62
  verifiers/v1/dialects/__init__.py,sha256=MNOYM2NdTlSxGK4VtoEjMtD5zya2MpjPDX5jMvZUWEo,736
63
- verifiers/v1/dialects/anthropic.py,sha256=mcZmNfby2dUaqfFlYHntlVbzEyS_eq6O2V_Hl7xqIPQ,25725
64
- verifiers/v1/dialects/base.py,sha256=0fowpPl8mrxFM9xZGPpBvse5kFgYefUnyfNjxKfug-0,15425
63
+ verifiers/v1/dialects/anthropic.py,sha256=rkxS8K4xvSTZwS86Mz_a4MfHzrkYf9Vvu8wCerzVNIw,26111
64
+ verifiers/v1/dialects/base.py,sha256=7peaHJGdVT93G3erTnlAcRJi7tGvJ9BUkd_W5O4HGms,16140
65
65
  verifiers/v1/dialects/chat.py,sha256=KOrDW2f4WMLl37Pv2EilKddCXUUgRNjl4tQseMiPC-o,23775
66
- verifiers/v1/dialects/responses.py,sha256=Z2WulSL14XyqlbdY_rVZKj0aluOeOlec9jmXXw9-FDI,28688
66
+ verifiers/v1/dialects/responses.py,sha256=GXI4xzvZWL5Dugi9uHPhrSdgV6RQ5zQuuPfzVztu2Go,30457
67
67
  verifiers/v1/envs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
68
68
  verifiers/v1/envs/agentic_judge/__init__.py,sha256=LfEcRHLG_siKzhzWmODDXWSLpLgfum3UFdQDK9XG0gk,325
69
69
  verifiers/v1/envs/agentic_judge/env.py,sha256=GovnHqgRm8nQLAM7wMx57-A_wJBRYopsfiy2Bgf32nA,15339
@@ -118,14 +118,14 @@ verifiers/v1/harnesses/terminus_2/harness.py,sha256=cKQJluW01mkxEccTLFwSWsVMavyi
118
118
  verifiers/v1/harnesses/terminus_2/program.py,sha256=X8MZdyItU3gbXGXnGOlJ9LWcUTnqIDaxlhkZvoRiqKY,2703
119
119
  verifiers/v1/harnesses/utils/__init__.py,sha256=eTS-Ft2HwoLiYn5u1t_KueLmaZB_W12cdYmcVsE-G5A,364
120
120
  verifiers/v1/harnesses/utils/compaction.py,sha256=DeY6CYLEsv7-vJm26sT2mRaPHu3W2ywiZhvFr_pi6Hg,10495
121
- verifiers/v1/harnesses/utils/core.py,sha256=r7kYmIr27jGXIgGHzYwaqmEkTZc7r310mxRH2tc_slc,18498
121
+ verifiers/v1/harnesses/utils/core.py,sha256=emAeNqtha__3cR0u7pvoQGnl2sE57wadE0wlKGtO1Yk,18520
122
122
  verifiers/v1/harnesses/utils/install.py,sha256=BDkkUVZ1brSzCrbQOvMO5mVIE2DsV2pE7rTC5yROYUw,2992
123
123
  verifiers/v1/harnesses/utils/launch.py,sha256=RqPuUY-kT2XVuLSGtXW7kRQEaP-o2KzkiJ7vcoo2neM,2883
124
124
  verifiers/v1/harnesses/utils/mcp.py,sha256=oNhKfLjAW-wScJcDIsW_HfuvKY-JkaGBBZrRLqgRK1g,7355
125
125
  verifiers/v1/interception/__init__.py,sha256=5Kvo7tcBUVaa65s9A1NiOH3CPpzw-P5btXwxB4Nbbvs,4251
126
126
  verifiers/v1/interception/base.py,sha256=Mg16Y104lyZilF9shdivdqYoqz4OYp978i_myyrulu8,2529
127
127
  verifiers/v1/interception/pool.py,sha256=sk_zcdTV8EVRuzqlYMXxon_42tSDAlFvwcyOO2EF7hw,6473
128
- verifiers/v1/interception/server.py,sha256=wxw67OQkLgzvt1HMJyjYmdcoIhn4vuJBe5oXZN-MIVU,54299
128
+ verifiers/v1/interception/server.py,sha256=DnPvQcZZX0JYvxrTLOQuJn4QFX5Z44edGjpvfdxZmRw,45773
129
129
  verifiers/v1/interception/tunnel/__init__.py,sha256=eVNZJszj6myrKhx9jmJz7VRd9qlfj0AUvUZwI2bXEj8,893
130
130
  verifiers/v1/interception/tunnel/base.py,sha256=DZB6uPLwM4Qy7n7m0vKeyg-Px87MhsmpYTpe2vpPEng,1998
131
131
  verifiers/v1/interception/tunnel/custom.py,sha256=yL4UbGf4bAuMsxk42mEP-t1wITQCT8BE9FHQIz0qRMA,1708
@@ -190,8 +190,8 @@ verifiers/v1/utils/retries.py,sha256=Y2ZgrAjn-qNkRKZP_RVNL_7EK0iaRcZeCa404lpGYi4
190
190
  verifiers/v1/utils/scope.py,sha256=bzUEiWDOVdbj7dXOwGOlzsQ_-Xu0j9NR5TalQB-8TS8,838
191
191
  verifiers/v1/utils/score.py,sha256=493yJVMw8teCu9JxapxMFPFzI0hNUqdo0Y2nGW4kckk,6200
192
192
  verifiers/v1/utils/version.py,sha256=-obEo_-l9-D8FLef4hYxncOe-uJpxrM1g2Hig_37Sgs,1607
193
- verifiers-0.3.2.dev131.dist-info/METADATA,sha256=OUiO38608oHRrsfZlI0G-wWxevOOaVxNHkiDzHpuk0A,4161
194
- verifiers-0.3.2.dev131.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
195
- verifiers-0.3.2.dev131.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
196
- verifiers-0.3.2.dev131.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
197
- verifiers-0.3.2.dev131.dist-info/RECORD,,
193
+ verifiers-0.3.2.dev133.dist-info/METADATA,sha256=Ff2GNRZ7xB_YdcKn5Cu6_FLNmVH6PuNJYVXZ-brj6mE,4161
194
+ verifiers-0.3.2.dev133.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
195
+ verifiers-0.3.2.dev133.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
196
+ verifiers-0.3.2.dev133.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
197
+ verifiers-0.3.2.dev133.dist-info/RECORD,,