Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion docs/v1/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ A set of tools defined by the taskset that are installed as MCP servers into the

## Trace

A trace records the message graph, rewards, metrics, errors, etc. When using verifiers for training with [prime-rl](https://github.com/PrimeIntellect-ai/prime-rl), it stores additional information such as tokens and logprobs, built incrementally using [renderers](https://github.com/PrimeIntellect-ai/renderers).
A trace records the message graph, rewards, metrics, errors, and one per-call record (`ModelCall`) per provider exchange (its model, sampling, finish reason, usage, timing, and any error), etc. When using verifiers for training with [prime-rl](https://github.com/PrimeIntellect-ai/prime-rl), it stores additional information such as tokens and logprobs, built incrementally using [renderers](https://github.com/PrimeIntellect-ai/renderers).
Comment thread
cursor[bot] marked this conversation as resolved.

## Documentation

Expand Down
1 change: 1 addition & 0 deletions skills/evaluate-environments/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ For each representative sample inspect:
- named `rewards`, aggregate `reward`, and `metrics`;
- persisted `info` artifacts;
- `error`/`errors` and boundary type;
- per-call `calls` records (model, sampling, finish reason, usage, timing, error) linked to the graph;
- usage and stage timing;
- token/mask/logprob fields when using the training client.

Expand Down
6 changes: 6 additions & 0 deletions tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ 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 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.model and call.sampling is not None
assert call.time.duration > 0


@pytest.mark.e2e
Expand Down
2 changes: 2 additions & 0 deletions verifiers/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
Branch,
Error,
EvalRunInfo,
ModelCall,
RunInfo,
TimeSpan,
Timing,
Expand Down Expand Up @@ -172,6 +173,7 @@
"AgentInfo",
"RunInfo",
"EvalRunInfo",
"ModelCall",
"TrainRunInfo",
"VersionInfo",
"State",
Expand Down
50 changes: 41 additions & 9 deletions verifiers/v1/dialects/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from dataclasses import dataclass, field

from anthropic.types import Message as AnthropicMessage
from anthropic.types import Usage as AnthropicUsage

from verifiers.v1.dialects.base import Dialect, StreamParser, parse_sse_event
from verifiers.v1.types import (
Expand All @@ -21,6 +22,7 @@
ImageUrlSource,
Messages,
Response,
Sampling,
SamplingConfig,
SystemMessage,
TextContentPart,
Expand Down Expand Up @@ -242,11 +244,35 @@ def finish(self) -> Response:
return response_from_wire(self.validate_response(self.message))


class ModdedUsage(AnthropicUsage):
"""The SDK closes `service_tier` to a fixed Literal, but Anthropic-compatible gateways
report their own tiers (e.g. Prime's `provisioned`). Widen to a plain string — we don't
consume it — so parsing stays lenient about the label instead of dropping it."""

service_tier: str | None = None # type: ignore[assignment]


class ModdedAnthropicMessage(AnthropicMessage):
usage: ModdedUsage # type: ignore[assignment]


class AnthropicDialect(Dialect[dict, AnthropicMessage]):
sampling_fields = frozenset(
{
"temperature",
"top_p",
"top_k",
"max_tokens",
"stop_sequences",
"thinking",
"tool_choice",
"output_config",
}
)
Comment thread
cursor[bot] marked this conversation as resolved.
routes = ("/v1/messages",)
aux_routes = ("/v1/messages/count_tokens",)
upstream_path = "/v1/messages"
response_type = AnthropicMessage
response_type = ModdedAnthropicMessage

def auth_headers(self, api_key: str) -> dict[str, str]:
return {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
Expand Down Expand Up @@ -276,17 +302,23 @@ def parse_request(self, body: dict) -> tuple[Messages, list[Tool] | None]:
def parse_response(self, response: AnthropicMessage) -> Response:
return response_from_wire(response)

def validate_response(self, raw: dict) -> AnthropicMessage:
usage = raw.get("usage")
tier = usage.get("service_tier") if usage else None
if tier not in (None, "standard", "priority", "batch"):
raw = {**raw, "usage": usage.copy()}
raw["usage"].pop("service_tier")
return super().validate_response(raw)

def stream_parser(self) -> StreamParser:
return AnthropicStreamParser(self.validate_response)

def parse_sampling(self, body: dict) -> Sampling:
settings = {k: v for k, v in body.items() if k in self.sampling_fields}
# Lift `output_config.effort` (where `apply_overrides` puts the eval's
# reasoning effort) onto the typed knob; keep any other output-config keys.
if isinstance(config := settings.get("output_config"), dict):
config = dict(config)
if config.get("effort"):
settings["reasoning_effort"] = config.pop("effort")
if config:
settings["output_config"] = config
else:
settings.pop("output_config")
return Sampling.model_validate(settings)

def apply_overrides(self, body: dict, model: str, sampling: SamplingConfig) -> dict:
# Preserve native fields except the eval's model + sampling. `temperature`/`top_p` are
# authoritative (always dropped, the eval's applied if set); `max_tokens` is required by
Expand Down
16 changes: 15 additions & 1 deletion verifiers/v1/dialects/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from pydantic import BaseModel
from pydantic_core import from_json

from verifiers.v1.types import Messages, Response, SamplingConfig, Tool
from verifiers.v1.types import Messages, Response, Sampling, SamplingConfig, Tool

ReqT = TypeVar("ReqT")
RespT = TypeVar("RespT", bound=BaseModel)
Expand Down Expand Up @@ -121,6 +121,12 @@ class Dialect(ABC, Generic[ReqT, RespT]):
`dialects.DIALECTS` and a harness speaking that format works end-to-end (the eval client and
interception server are generic over this interface)."""

sampling_fields: ClassVar[frozenset[str]] = frozenset()
"""Request keys that are call settings — what shapes generation given the same
conversation: decoding knobs, budgets/stops, reasoning effort, output contract.
A whitelist, so payload, conversation state, and tracking fields can never leak
into the per-call record by omission; an unlisted knob is simply not recorded."""

routes: ClassVar[tuple[str, ...]]
"""The endpoint path(s) a program's SDK posts model turns to. The interception server serves
one handler per route, so the wire format is resolved from the route the SDK chose (it
Expand Down Expand Up @@ -167,6 +173,14 @@ def error_body(self, message: str) -> dict:
def parse_request(self, body: ReqT) -> tuple[Messages, list[Tool] | None]:
"""The native request -> vf prompt + tools (for the trace)."""

def parse_sampling(self, body: ReqT) -> Sampling:
"""The native request's call settings -> the canonical `Sampling` (for the
trace's per-call records): the `sampling_fields` whitelist, with this format's
aliases mapped onto the typed knobs; dialect-specific keys ride as extras."""
return Sampling.model_validate(
{k: v for k, v in body.items() if k in self.sampling_fields}
)

@abstractmethod
def parse_response(self, response: RespT) -> Response:
"""A native (non-streamed) response -> the vf `Response` we consume."""
Expand Down
61 changes: 45 additions & 16 deletions verifiers/v1/dialects/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Message,
Messages,
Response,
Sampling,
SamplingConfig,
SystemMessage,
Tool,
Expand Down Expand Up @@ -271,27 +272,47 @@ 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,
}
return response_from_wire(ModdedChatCompletion.model_validate(completion))


class ChatDialect(Dialect[dict, ChatCompletion]):
sampling_fields = frozenset(
{
"temperature",
"top_p",
"top_k",
"min_p",
"max_tokens",
"max_completion_tokens",
"reasoning_effort",
"seed",
"stop",
"n",
"logprobs",
"top_logprobs",
"logit_bias",
"frequency_penalty",
"presence_penalty",
"repetition_penalty",
"response_format",
"tool_choice",
"parallel_tool_calls",
}
)
Comment thread
cursor[bot] marked this conversation as resolved.
routes = ("/v1/chat/completions",)
upstream_path = "/chat/completions"
response_type = ModdedChatCompletion
Expand All @@ -311,6 +332,14 @@ def parse_request(self, body: dict) -> tuple[Messages, list[Tool] | None]:
tool_names[call.id] = call.name
return messages, parse_tools(body.get("tools"))

def parse_sampling(self, body: dict) -> Sampling:
settings = {k: v for k, v in body.items() if k in self.sampling_fields}
# Canonicalize the max-tokens alias; when both ride the wire (an eval override
# on top of a harness's `max_completion_tokens`), the override wins.
if (mct := settings.pop("max_completion_tokens", None)) is not None:
settings.setdefault("max_tokens", mct)
return Sampling.model_validate(settings)

def parse_response(self, response: ChatCompletion) -> Response:
return response_from_wire(response)

Expand Down
31 changes: 31 additions & 0 deletions verifiers/v1/dialects/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ImageUrlSource,
Messages,
Response,
Sampling,
SamplingConfig,
SystemMessage,
TextContentPart,
Expand Down Expand Up @@ -277,6 +278,20 @@ def finish(self) -> Response:


class ResponsesDialect(Dialect[dict, OpenAIResponse]):
sampling_fields = frozenset(
{
"temperature",
"top_p",
"max_output_tokens",
"max_tool_calls",
"reasoning",
"text",
"tool_choice",
"parallel_tool_calls",
"top_logprobs",
"truncation",
}
)
routes = ("/v1/responses",)
upstream_path = "/responses"
response_type = OpenAIResponse
Expand All @@ -286,6 +301,22 @@ def is_terminal_event(self, chunk: bytes) -> bool:
# trailing `[DONE]`, so the turn-ending event is the final event, not the sentinel.
return any(marker in chunk for marker in _TERMINAL_MARKERS)

def parse_sampling(self, body: dict) -> Sampling:
settings = {k: v for k, v in body.items() if k in self.sampling_fields}
# Lift `reasoning.effort` onto the typed knob; keep any other reasoning keys
# (e.g. `summary`) as the wire sent them.
if isinstance(reasoning := settings.get("reasoning"), dict):
reasoning = dict(reasoning)
if reasoning.get("effort"):
settings["reasoning_effort"] = reasoning.pop("effort")
if reasoning:
settings["reasoning"] = reasoning
else:
settings.pop("reasoning")
if "max_output_tokens" in settings:
settings["max_tokens"] = settings.pop("max_output_tokens")
return Sampling.model_validate(settings)

def parse_request(self, body: dict) -> tuple[Messages, list[Tool] | None]:
prompt: Messages = []
if instructions := body.get("instructions"):
Expand Down
17 changes: 15 additions & 2 deletions verifiers/v1/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ def __init__(self, message: str = "", *, status_code: int = 502) -> None:

class OverlongPromptError(ProviderError):
"""The prompt exceeded the model's context window — a budget limit, ended as a clean
truncation rather than recorded as an error."""
truncation rather than recorded as an error. Defaults to a 400 (what the interception
server surfaces for it — deterministic, so an SDK never retries it); `model_error`
keeps the provider's real status when the failure carried one."""

def __init__(self, message: str = "", *, status_code: int = 400) -> None:
super().__init__(message, status_code=status_code)


class HarnessError(RolloutError):
Expand Down Expand Up @@ -128,10 +133,18 @@ def model_error(
becomes a plain `ProviderError`. `status_code` is the HTTP status surfaced to the harness (whose
SDK then retries 5xx/429/timeout and not 4xx); derived from an SDK error when not given. Accepts
an SDK error (the renderer) or the provider's raw error body (the httpx proxy)."""
from openai import APIStatusError

# 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)
# Keep the provider's real status when the failure carried one; else the class
# default (the 400 the interception server surfaces for overlong prompts).
if status_code is None and isinstance(e, APIStatusError):
status_code = e.status_code
return OverlongPromptError(
text, **({} if status_code is None else {"status_code": status_code})
)
return ProviderError(
text,
status_code=status_code if status_code is not None else _provider_status(e),
Expand Down
Loading
Loading