Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1ac73f4
feat(v1): record raw per-call request/response on the trace
mikasenghaas Jul 17, 2026
f656257
refactor: drop TTFT, type the call's dialect, unhide record_call
mikasenghaas Jul 17, 2026
e1ff38d
fix(v1): record mid-relay and pre-send call failures
mikasenghaas Jul 17, 2026
970f63b
revert: stamp the call's wire format by route, not a dialect literal
mikasenghaas Jul 17, 2026
985f166
fix(v1): record the exchange when a non-stream commit fails
mikasenghaas Jul 17, 2026
f00310d
fix(v1): keep response data on stream-failure call records
mikasenghaas Jul 17, 2026
7a7f0a9
refactor: one commit-side record_call per path, in a finally
mikasenghaas Jul 17, 2026
5087cf0
refactor: one per-exchange record_call per interception path
mikasenghaas Jul 17, 2026
bed6e98
fix(v1): sound per-call records under finally-time recording
mikasenghaas Jul 17, 2026
cd9a5a1
feat(v1): per-call status, provider headers on failed exchanges
mikasenghaas Jul 17, 2026
549d058
fix(v1): real status on overlong per-call records
mikasenghaas Jul 18, 2026
78ee3fe
fix(v1): derive provider headers from SDK status errors too
mikasenghaas Jul 18, 2026
02dcb90
docs: status is HTTP diagnostics, error is the failure signal
mikasenghaas Jul 18, 2026
9ab7ef6
fix(v1): every failed call records its surfaced status - rename statu…
mikasenghaas Jul 18, 2026
e7be495
chore: export ModelCall from verifiers.v1
mikasenghaas Jul 18, 2026
4ab20a6
refactor(v1)!: type the per-call records, drop raw request/response
mikasenghaas Jul 18, 2026
3dae631
fix(v1): conversation-state ids are payload, not settings
mikasenghaas Jul 18, 2026
f4b42d4
refactor(v1): whitelist the per-call sampling capture
mikasenghaas Jul 18, 2026
69f6b71
refactor(v1): dialects own the per-call sampling translation
mikasenghaas Jul 18, 2026
b1026ca
chore: parse_sampling speaks Sampling, not SamplingConfig
mikasenghaas Jul 18, 2026
432c5c4
fix(v1): complete the reasoning-effort reverse mappings
mikasenghaas Jul 18, 2026
12f4132
revert: keep ProviderError.status_code, no rename break
mikasenghaas Jul 18, 2026
50f007b
feat(v1)!: finish_reason lives on the call, not the node
mikasenghaas Jul 18, 2026
ac77409
docs: ModelCall.status is the upstream status, not the relayed one
mikasenghaas Jul 18, 2026
bae1e04
feat(v1)!: usage lives on the call, not the node
mikasenghaas Jul 18, 2026
4d3edb8
fix(v1): canonicalize the chat max-tokens alias on call records
mikasenghaas Jul 18, 2026
fd3c22e
move calls
mikasenghaas Jul 18, 2026
7fd980c
fix(v1): preserve gateway service tiers on Anthropic responses
mikasenghaas Jul 18, 2026
dc2b3f3
chore: drop the dialect unit tests
mikasenghaas Jul 18, 2026
2828100
chore: keep the TRACE_VERSION docstring unversioned
mikasenghaas Jul 18, 2026
5062742
refactor(v1): status_code lives on Error, not beside it
mikasenghaas Jul 18, 2026
cf18f49
docs: mention per-call ModelCall records in the trace overview
mikasenghaas Jul 18, 2026
a02c7f5
docs: add per-call records to the evaluate skill's trace checklist
mikasenghaas Jul 18, 2026
ce55ead
chore: ModelCall.sampling typed as Sampling; drop Branch.calls docstring
mikasenghaas Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ async def test_single_turn(run_v1, harness, harness_runtime, tmp_path):
assert trace.errors == []
assert trace.num_turns == 1
assert trace.reward == 1.0
# Every sampled turn has one raw per-call record, linked to its assistant node.
sampled = [i for i, n in enumerate(trace.nodes) if n.sampled]
assert [c.node for c in trace.calls if c.error is None] == sampled
for call in trace.calls:
assert call.request is not None
assert call.time.duration > 0
if call.error is None:
assert call.response is not None


@pytest.mark.e2e
Expand Down
2 changes: 2 additions & 0 deletions verifiers/v1/clients/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class RelayReply:
content_type: str
chunks: AsyncIterator[bytes]
close: Callable[[], Awaitable[None]]
headers: dict[str, str] | None = None
"""Provider response headers, for the trace's per-call records."""


class Client(ABC):
Expand Down
8 changes: 7 additions & 1 deletion verifiers/v1/clients/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ async def get_response(
raise model_error(
f"malformed upstream response: {type(e).__name__}: {e}",
status_code=502,
headers=dict(resp.headers),
) from e
# The interception server returns this full native provider object to the program.
response.raw = raw
response.raw_headers = dict(resp.headers)
Comment thread
mikasenghaas marked this conversation as resolved.
Outdated
return response

def _headers(
Expand Down Expand Up @@ -169,6 +171,7 @@ async def _request(
raise model_error(
f"upstream {e.response.status_code}: {e.response.text}",
status_code=e.response.status_code,
headers=dict(e.response.headers),
) from e
return response
if response.status_code < 400:
Expand All @@ -178,7 +181,9 @@ async def _request(
finally:
await response.aclose()
raise model_error(
f"upstream {response.status_code}: {text}", status_code=response.status_code
f"upstream {response.status_code}: {text}",
status_code=response.status_code,
headers=dict(response.headers),
)

async def relay(
Expand Down Expand Up @@ -217,6 +222,7 @@ async def chunks():
content_type=resp.headers.get("content-type", "text/event-stream"),
chunks=chunks(),
close=resp.aclose,
headers=dict(resp.headers),
)

async def relay_aux(
Expand Down
4 changes: 3 additions & 1 deletion verifiers/v1/dialects/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ def finish(self) -> Response:
for index, parts in self.partial_json.items():
self.blocks[index]["input"] = json.loads("".join(parts) or "{}")
self.message["content"] = [self.blocks[index] for index in sorted(self.blocks)]
return response_from_wire(self.validate_response(self.message))
response = response_from_wire(self.validate_response(self.message))
response.raw = self.message
return response


class AnthropicDialect(Dialect[dict, AnthropicMessage]):
Expand Down
31 changes: 15 additions & 16 deletions verifiers/v1/dialects/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,24 +271,23 @@ def finish(self) -> Response:
if self.reasoning_details:
self.message["reasoning_details"] = self.reasoning_details
head = self.head or {}
return response_from_wire(
ModdedChatCompletion.model_validate(
completion = {
"id": head.get("id", "vf-intercept"),
"object": "chat.completion",
"created": head.get("created", int(time.time())),
"model": head.get("model", ""),
"choices": [
{
"id": head.get("id", "vf-intercept"),
"object": "chat.completion",
"created": head.get("created", int(time.time())),
"model": head.get("model", ""),
"choices": [
{
"index": 0,
"message": self.message,
"finish_reason": self.finish_reason or "stop",
}
],
"usage": self.usage,
"index": 0,
"message": self.message,
"finish_reason": self.finish_reason or "stop",
}
)
)
],
"usage": self.usage,
}
response = response_from_wire(ModdedChatCompletion.model_validate(completion))
response.raw = completion
return response


class ChatDialect(Dialect[dict, ChatCompletion]):
Expand Down
4 changes: 3 additions & 1 deletion verifiers/v1/dialects/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,11 @@ def finish(self) -> Response:
events = self.terminal_events or self.events
for event in iter_sse_reverse(b"".join(events)):
if event.get("type") in FINAL_EVENTS:
return response_from_wire(
response = response_from_wire(
OpenAIResponse.model_validate(event["response"])
)
response.raw = event["response"]
return response
raise ValueError("Responses stream ended without a terminal event")


Expand Down
19 changes: 16 additions & 3 deletions verifiers/v1/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,18 @@ class ProviderError(RolloutError):
(5xx/429/timeout) and not deterministic ones (4xx) — relayed from the provider, or chosen for a
transport fault."""

def __init__(self, message: str = "", *, status_code: int = 502) -> None:
def __init__(
self,
message: str = "",
*,
status_code: int = 502,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.headers = headers
"""Provider response headers when the failure carried an HTTP response (request
ids, rate-limit diagnostics) — surfaced on the trace's per-call records."""


class OverlongPromptError(ProviderError):
Expand Down Expand Up @@ -121,7 +130,10 @@ def _provider_status(e: OpenAIError | str) -> int:


def model_error(
e: OpenAIError | str, *, status_code: int | None = None
e: OpenAIError | str,
*,
status_code: int | None = None,
headers: dict[str, str] | None = None,
) -> ProviderError:
"""Map a provider failure to our error type: an overlong prompt (a budget limit the interception
server turns into a clean truncation) is told apart from any other provider call failure, which
Expand All @@ -131,8 +143,9 @@ def model_error(
# Some SDK errors stringify empty; fall back to the type so the message is never blank.
text = str(e) or (type(e).__name__ if isinstance(e, BaseException) else "")
if any(phrase in text.casefold() for phrase in _CONTEXT_LENGTH_PHRASES):
return OverlongPromptError(text)
return OverlongPromptError(text, headers=headers)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
return ProviderError(
text,
status_code=status_code if status_code is not None else _provider_status(e),
headers=headers,
)
10 changes: 7 additions & 3 deletions verifiers/v1/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,12 @@ def prompt_message_spans(
for span in tail_spans
]

def commit(self, response: Response, tools: list[Tool] | None = None) -> None:
_commit_turn(self, response)
def commit(self, response: Response, tools: list[Tool] | None = None) -> int:
"""Add this turn to the graph; returns the committed assistant node's id."""
assistant_id = _commit_turn(self, response)
if tools:
self.trace.tools = tools
return assistant_id


def prepare_turn(trace: Trace, prompt: list[Message]) -> PendingTurn:
Expand Down Expand Up @@ -486,7 +488,7 @@ def _attribute_kept_tokens(
node.kept_tokens = KeptTokens(ids=ids.copy(), counts=counts.copy())


def _commit_turn(turn: PendingTurn, response: Response) -> None:
def _commit_turn(turn: PendingTurn, response: Response) -> int:
trace = turn.trace
prompt = turn.prompt
tokens = response.tokens
Expand Down Expand Up @@ -596,6 +598,8 @@ def _commit_turn(turn: PendingTurn, response: Response) -> None:
# completion-aligned, so only the sampled node carries them).
_attribute_kept_tokens(trace, assistant_id, tokens.kept_tokens if tokens else None)

return assistant_id


# --- walking the graph (views) ---------------------------------------------------------

Expand Down
Loading
Loading