File size: 27,840 Bytes
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5845692
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5845692
 
 
0b2c941
 
 
 
 
5845692
 
 
 
 
 
 
 
 
 
 
 
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5845692
 
 
 
 
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5845692
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
5845692
0b2c941
 
 
 
 
 
 
 
 
 
 
 
5845692
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
"""The upstream leg: one HTTP client to a vLLM- or SGLang-compatible OpenAI server.

SGLang was excluded here because it genuinely could not do this: its chat route returned token
*text* with no ids (sgl-project/sglang#18378 asked for exactly this, for the same train/inference
consistency reason). That changed with sgl-project/sglang#30917, merged 2026-07-23, which added
`return_token_ids` to the OpenAI-compatible routes. It is on `main` and NOT in v0.5.16 β€” that
release has only `return_prompt_token_ids`, the prompt ids without the sampled ones β€” so an SGLang
endpoint is usable here only when built from main.

One shape difference, absorbed in `normalize_response` below: SGLang returns the prompt ids PER
CHOICE (`choices[0].prompt_token_ids`); vLLM returns them at the TOP LEVEL of the response.
Everything downstream reads the top-level field, so normalisation hoists SGLang's.

Two request params do all the work, and both are easy to get subtly wrong:

    return_token_ids=True   makes vLLM emit `response.prompt_token_ids` and `choice.token_ids`.
                            `prompt_token_ids` is the load-bearing one: it is the engine's own
                            tokenisation of the whole conversation so far, which is what lets turn
                            k+1 be matched against turn k by exact token prefix without us ever
                            tokenising locally.
    top_logprobs=0          must be SET, not omitted. vLLM only populates `logprobs.content[]` when
                            `top_logprobs` is not None, even with `logprobs=True`. Zero returns just
                            the sampled token's logprob, which is all training needs.

Neither is standard, so neither can be sent unconditionally. `capture_level` says how much this
particular endpoint tolerates, and it is discovered by probing rather than configured (see
`validate_llm`):

    tokens      prompt ids + sampled ids + aligned logprobs. vLLM with the two serving flags, or
                SGLang built from main. The only level that yields trainable rollouts.
    logprobs    logprobs but no ids. Nothing trainable can be built from these β€” an unpaired
                logprob has no token to attach to β€” so they are kept only as an eval diagnostic.
    text        neither. OpenAI's current models reject `logprobs` outright; Anthropic never had it.

Below `tokens` a rollout is an eval rollout: same path, same agents, reward and full trace, no token
fields. What must never happen is *looking* trainable while carrying nothing, which is why the level
travels with every response and no contract is written without it.
"""

from __future__ import annotations

import asyncio
import json
import logging
from typing import Any
from urllib.parse import urlsplit

import httpx

from .compat import diagnose, MAX_FIXES, ParamFix

logger = logging.getLogger(__name__)

# Ordered weakest-last: `CAPTURE_LEVELS.index` is how callers compare two levels.
CAPTURE_LEVELS = ("tokens", "logprobs", "text")


def auth_headers(api_key: str | None, header: str = "Authorization") -> dict[str, str]:
    """The one header that authenticates us to the upstream, or `{}` when there is no key.

    `Authorization` gets the `Bearer ` prefix the OpenAI spec asks for; any other header name gets
    the raw key, because the providers that use a custom header (`x-api-key`) want it bare. The
    header name is configurable because the OpenResponses compliance suite treats it as
    configuration, and because Anthropic's native route does not accept `Authorization`.

    Args:
        api_key (`str`, *optional*):
            The upstream credential. This is never the agent-facing key: that one is a capture
            session id and is minted per rollout (see `sessions`).
        header (`str`, *optional*, defaults to `"Authorization"`):
            Header to send it under.

    Returns:
        `dict[str, str]`: Headers to merge into the request.
    """
    if not api_key:
        return {}
    name = (header or "Authorization").strip()
    if name.lower() == "authorization":
        return {name: f"Bearer {api_key}"}
    return {name: api_key}


class UpstreamError(RuntimeError):
    """Any failure talking to the engine. Never leaks httpx types to callers."""


class UpstreamRequestError(UpstreamError):
    """The caller's request cannot be represented by the selected inference backend."""


class UpstreamHTTPError(UpstreamError):
    """Engine answered with a non-2xx status."""

    def __init__(
        self, status_code: int, body: dict[str, Any] | str | None = None
    ) -> None:
        self.status_code = status_code
        self.body = body
        detail = body
        if isinstance(body, dict):
            error = body.get("error")
            detail = error.get("message") if isinstance(error, dict) else error or body
        super().__init__(f"upstream returned {status_code}: {str(detail)[:400]}")


class UpstreamTimeoutError(UpstreamError):
    """Engine did not answer within the liveness ceiling."""


class UpstreamTransportError(UpstreamError):
    """Connection-level failure: refused, reset, DNS."""


def normalise_engine_base(url: str) -> str:
    """The engine root, with any trailing `/v1` removed.

    Anthropic's convention: the base excludes `/v1` and routes are `/v1/...`, so a caller who
    passes `https://api.anthropic.com/v1` would otherwise get `/v1/v1/messages`. OpenAI-compatible
    routes use `openai_api_base` instead.
    """
    base = url.rstrip("/")
    return base[: -len("/v1")] if base.endswith("/v1") else base


def openai_api_base(url: str) -> str:
    """The OpenAI-compatible API root that `/chat/completions` and `/models` are built on.

    The OpenAI SDK rule, as `OpenAIClient` applies it (#1237): a base with no path gets `/v1`, and
    a base with a path is already the API root. Stripping `/v1` and re-adding it broke every
    provider whose root is not `/v1`: Gemini's `.../v1beta/openai` became
    `.../v1beta/openai/v1/chat/completions`.
    """
    base = url.rstrip("/")
    return base if urlsplit(base).path else f"{base}/v1"


# Sampling knobs that make a *processed* logprob incomparable to a full-vocab recompute, mapped to the
# value that disables each one.
#
# vLLM's sampler masks the truncated tail to `-inf` and only then takes the log-softmax
# (`v1/sample/ops/topk_topp_sampler.py`: `apply_top_k_top_p` at line 135, `compute_logprobs` at 139,
# which is `logits.log_softmax(...)`). So under `--logprobs-mode processed_logprobs` with `top_p=0.95`,
# every captured logprob is the *renormalised* one β€” `log p_full(token) - log(kept_mass)` β€” while a
# trainer recomputing over the full vocabulary gets `log p_full(token)`. The captured number is
# uniformly too high, so GRPO's step-0 importance ratio `exp(recompute - captured)` comes out at
# `kept_mass` rather than 1, and the penalties are worse than a uniform shift because they reorder.
#
# For a training rollout the sampling distribution must BE the policy distribution β€” that is what makes
# the data on-policy β€” so truncation is not a feature to preserve here, it is the bug. TRL's own
# GRPOConfig defaults `top_p` to 1.0 for the same reason. At the `tokens` tier these are therefore set
# to their no-op values; at `logprobs`/`text` they are left exactly as the harness sent them, because an
# eval rollout should score the model the harness actually asked for.
#
# The original request is recorded as `requested_sampling_params`. `sampling_params` records the
# actual submitted policy, including compatibility edits; explicit training sessions reject edits
# that change the pinned policy.
NON_POLICY_SAMPLING: dict[str, float | int] = {
    "top_p": 1.0,
    "top_k": -1,
    "min_p": 0.0,
    "frequency_penalty": 0.0,
    "presence_penalty": 0.0,
    "repetition_penalty": 1.0,
}


def training_sampling(sampling: dict[str, Any] | None) -> dict[str, float | int]:
    """Validate an explicit full-vocabulary training policy for a capture session."""
    if sampling is None:
        return {}
    if not isinstance(sampling, dict):
        raise ValueError("sampling must be a JSON object")
    unknown = set(sampling) - {"temperature", *NON_POLICY_SAMPLING}
    if unknown:
        raise ValueError(
            f"unsupported training sampling fields: {', '.join(sorted(unknown))}"
        )
    temperature = sampling.get("temperature")
    if (
        isinstance(temperature, bool)
        or not isinstance(temperature, (float, int))
        or not 0 < temperature < float("inf")
    ):
        raise ValueError("training sampling requires a finite positive temperature")
    for key, value in sampling.items():
        if key == "temperature":
            continue
        if isinstance(value, bool) or not isinstance(value, (float, int)):
            raise ValueError(f"training sampling {key} must be numeric")
        if key == "top_k" and value == 0:
            continue
        if value != NON_POLICY_SAMPLING[key]:
            raise ValueError(
                f"training sampling requires {key}={NON_POLICY_SAMPLING[key]}"
            )
    return {"temperature": float(temperature), **NON_POLICY_SAMPLING}


def truncating_params(request: dict[str, Any]) -> dict[str, Any]:
    """The entries of `request` that would narrow or distort the sampling distribution.

    Args:
        request (`dict[str, Any]`):
            A chat-completions body.

    Returns:
        `dict[str, Any]`: Requested key -> value, for each knob set to something other than its no-op.
    """
    found: dict[str, Any] = {}
    for key, neutral in NON_POLICY_SAMPLING.items():
        value = request.get(key)
        if (
            value is None
            or not isinstance(value, (int, float))
            or isinstance(value, bool)
        ):
            continue
        # `top_k` is disabled by -1 in vLLM and by 0 elsewhere; neither truncates.
        if key == "top_k" and value in (-1, 0):
            continue
        if float(value) != float(neutral):
            found[key] = value
    return found


def prepare_request(
    request: dict[str, Any],
    *,
    served_model: str | None = None,
    capture_level: str = "tokens",
) -> dict[str, Any]:
    """Add the params that make a response capturable. Mutates and returns `request`.

    Only the top level of `request` is mutated. Nested message dicts are copied before they are
    rewritten, because the caller's messages are the same objects the graph stores: rewriting them
    in place would mean a captured turn no longer records what the harness actually sent.

    Args:
        request (`dict[str, Any]`):
            The chat-completions body, already normalised by the dialect transformer.
        served_model (`str`, *optional*):
            Accepted for symmetry with the caller; the model name is set by the server.
        capture_level (`str`, *optional*, defaults to `"tokens"`):
            How much this endpoint tolerates. See the module docstring.

    Returns:
        `dict[str, Any]`: The same object, edited.
    """
    if capture_level == "tokens":
        request["logprobs"] = True
        request["return_token_ids"] = True
        # See `NON_POLICY_SAMPLING`: a processed logprob is taken after these are applied, so leaving
        # them on would silently bias every importance ratio computed from this rollout.
        for key in truncating_params(request):
            request[key] = NON_POLICY_SAMPLING[key]
    elif capture_level == "logprobs":
        request["logprobs"] = True
    # At `text` nothing is injected at all. Current OpenAI models answer `logprobs` with a 400, and
    # a rejected request is worse than a missing diagnostic: the agent's turn is simply lost.

    # Not `setdefault`: that keeps an explicitly-provided `None`, and vLLM only fills
    # `logprobs.content[]` when `top_logprobs` is not None. A harness that sends
    # `"top_logprobs": null` would then get a normal-looking response with no logprobs at all, so
    # every turn it produced would be silently untrainable.
    if capture_level != "text" and request.get("top_logprobs") is None:
        request["top_logprobs"] = 0

    # vLLM reads a prior turn's thinking from `reasoning`, while the dialect transformers emit the
    # canonical `reasoning_content`. Without this rename an earlier turn's interleaved thinking
    # renders as an empty `<think></think>` and the prompt silently differs from what the model
    # actually produced β€” which breaks prefix matching for the turn after it.
    #
    # That rename is a vLLM accommodation, so at `text` the field is dropped instead: a non-standard
    # key inside a message is the same 400 hazard as a non-standard top-level param, and there is no
    # prefix matching at that level for it to protect.
    messages = request.get("messages")
    if isinstance(messages, list):
        rewritten: list[Any] = []
        for message in messages:
            if (
                isinstance(message, dict)
                and message.get("reasoning_content") is not None
            ):
                message = dict(message)
                if capture_level == "text":
                    message.pop("reasoning_content")
                else:
                    message["reasoning"] = message.pop("reasoning_content")
            rewritten.append(message)
        request["messages"] = rewritten

    # `_served_model` is an internal marker the dialect transformers read; the server sets it before
    # transforming and the transformer strips it. Setting it here would be too late to be read and
    # would leak an unknown field to the engine, so this only guarantees it is gone.
    request.pop("_served_model", None)
    return request


def normalize_response(response: dict[str, Any]) -> dict[str, Any]:
    """Canonicalise the engine's response shape in place, so callers see one shape.

    Two engines, two placements for the same field. vLLM puts the prompt ids at the top level of the
    response; SGLang puts them on each choice (`ChatCompletionResponseChoice.prompt_token_ids`, added
    by sgl-project/sglang#30917). Every reader downstream β€” `check_upstream_response`, the capture
    server's `_ingest`, the UI β€” looks only at the top level, so hoist rather than teach each of them
    both spellings.

    Hoisted from `choices[0]` specifically, and only when the top level is empty: the prompt is a
    property of the request, so with n>1 every choice carries the same list, and a top-level value
    that is already present is the engine's own and must win.
    """
    choices = response.get("choices")
    if not isinstance(choices, list):
        return response

    if (
        not response.get("prompt_token_ids")
        and choices
        and isinstance(choices[0], dict)
    ):
        hoisted = choices[0].get("prompt_token_ids")
        if hoisted:
            response["prompt_token_ids"] = hoisted

    for choice in choices:
        if not isinstance(choice, dict):
            continue

        message = choice.get("message")
        if isinstance(message, dict):
            if (
                message.get("reasoning_content") is None
                and message.get("reasoning") is not None
            ):
                message["reasoning_content"] = message.pop("reasoning")

        # Copy each token id onto its logprob entry, and CHECK the pairing while doing it.
        #
        # The ids and the logprobs arrive on two separate channels and are joined by index. Equal
        # length was the only guard, which an equal-length-but-SHIFTED pairing passes β€” a stop or EOS
        # token present in one channel and not the other is enough β€” after which every logprob is
        # attributed to its neighbour's token and training proceeds silently on the misattribution.
        #
        # When the engine runs with --return-tokens-as-token-ids the check is free and exact: each
        # entry's `token` field literally reads `token_id:{id}`, so it can be compared against
        # `token_ids[i]` at every position rather than inspected once for a warning. A disagreement
        # drops the logprobs, which is what every other unusable-logprob path already does β€” the ids
        # stay as real context and `sequence_for` masks the turn out of training.
        token_ids = choice.get("token_ids")
        entries = ((choice.get("logprobs") or {}).get("content")) or []
        if isinstance(token_ids, list) and len(token_ids) == len(entries):
            mismatch = _pairing_mismatch(token_ids, entries)
            if mismatch is not None:
                position, declared, actual = mismatch
                logger.warning(
                    "token id / logprob channels disagree at position %d (ids say %s, logprobs say "
                    "%s); dropping the logprobs for this turn rather than training on a shifted "
                    "pairing",
                    position,
                    declared,
                    actual,
                )
                choice["logprobs"] = None
            else:
                for token_id, entry in zip(token_ids, entries):
                    if isinstance(entry, dict):
                        entry.setdefault("token_id", token_id)
    return response


def _pairing_mismatch(
    token_ids: list[Any], entries: list[Any]
) -> tuple[int, Any, Any] | None:
    """The first position where the two channels disagree, or `None`.

    Only positions whose `token` field is in the `token_id:{id}` form can be checked; a server without
    `--return-tokens-as-token-ids` emits token TEXT and is skipped, since decoding text back to an id
    would need the tokenizer this design deliberately does not load.
    """
    for position, (token_id, entry) in enumerate(zip(token_ids, entries)):
        if not isinstance(entry, dict):
            continue
        token = entry.get("token")
        if not isinstance(token, str) or not token.startswith("token_id:"):
            continue
        declared = token[len("token_id:") :]
        if declared != str(token_id):
            return position, token_id, declared
    return None


def _retry_after_seconds(response: httpx.Response) -> float | None:
    """The `Retry-After` delay a provider asked for, in seconds, if it gave a usable one.

    Only the delta-seconds form is honoured. The HTTP-date form is legal but rare here, and parsing
    it wrong would either sleep for hours or not at all.
    """
    raw = response.headers.get("retry-after")
    if not raw:
        return None
    try:
        return max(0.0, float(raw.strip()))
    except ValueError:
        return None


class InferenceClient:
    """Async client to one engine. One instance per server, shared across sessions."""

    # A high ceiling, not a per-request budget. Callers impose their own deadline; this exists only
    # so a wedged engine cannot pin a connection forever.
    _LIVENESS_TIMEOUT_S = 900.0
    _CONNECT_TIMEOUT_S = 30.0

    # Hosted providers rate-limit; a local engine effectively never does. Without this a single 429
    # became a 502 to the agent, which truncates its trajectory while leaving a graph that looks
    # perfectly well-formed β€” the failure class ATIF reconciliation exists to catch.
    _RETRY_STATUSES = frozenset({408, 409, 429, 500, 502, 503, 504})
    _MAX_ATTEMPTS = 3
    _BACKOFF_S = 2.0
    # A `Retry-After` longer than this is not worth honouring inside one rollout; the sandbox has its
    # own agent timeout and would be killed waiting.
    _MAX_RETRY_AFTER_S = 60.0

    def __init__(
        self,
        base_url: str,
        *,
        served_model: str | None = None,
        api_key: str | None = None,
        auth_header: str = "Authorization",
        capture_level: str = "tokens",
        provider: str = "openai",
    ) -> None:
        if provider not in ("openai", "anthropic", "hf", "vllm"):
            raise ValueError(f"unknown upstream provider: {provider}")
        self.provider = provider
        if provider == "anthropic":
            capture_level = "text"
            auth_header = "x-api-key"
        self.base_url = (
            f"{normalise_engine_base(base_url)}/v1"
            if provider == "anthropic"
            else openai_api_base(base_url)
        )
        self.served_model = served_model
        self.api_key = api_key or None
        self.auth_header = auth_header or "Authorization"
        self.capture_level = capture_level
        # Fixes discovered from the provider's own 400s, applied to every later request. Cached
        # because they are a property of the endpoint and the model, not of one call: rediscovering
        # them per request would double the call count for the life of the server.
        self.param_fixes: list[ParamFix] = []
        self._client: httpx.AsyncClient | None = None

    async def _get_client(self) -> httpx.AsyncClient:
        if self._client is None or self._client.is_closed:
            self._client = httpx.AsyncClient(
                base_url=self.base_url,
                timeout=httpx.Timeout(
                    self._LIVENESS_TIMEOUT_S, connect=self._CONNECT_TIMEOUT_S
                ),
                headers={
                    **auth_headers(self.api_key, self.auth_header),
                    **(
                        {"anthropic-version": "2023-06-01"}
                        if self.provider == "anthropic"
                        else {}
                    ),
                },
            )
        return self._client

    async def aclose(self) -> None:
        if self._client is not None and not self._client.is_closed:
            await self._client.aclose()
        self._client = None

    async def completion(self, request: dict[str, Any]) -> dict[str, Any]:
        """One non-streaming chat completion, prepared for capture and normalised on the way back."""
        if self.provider == "anthropic":
            from .providers import anthropic_request, anthropic_response

            body = anthropic_request(
                request, self.served_model or request.get("model", "")
            )
            # Native request fields cannot pass through OpenAI-specific parameter repair.
            native_headers = {
                key: value
                for key, value in request.get("_openenv_native_headers", {}).items()
                if key in {"anthropic-beta", "anthropic-version"}
            }
            payload = await self._post_with_retries(
                "/messages", body, headers=native_headers
            )
            response = anthropic_response(
                payload,
                native_passthrough=request.get("_openenv_native_request") is not None,
            )
            response["_openenv_sampling"] = {
                k: body[k] for k in ("temperature", "top_p", "top_k") if k in body
            }
            return response
        body = prepare_request(
            dict(request),
            served_model=self.served_model,
            capture_level=self.capture_level,
        )
        payload = await self._post("/chat/completions", body)
        # The body now includes any compatibility edits made by `_post`. Keep the actual
        # submitted policy separate from the harness's request; the server removes this marker.
        payload["_openenv_sampling"] = {
            key: body[key]
            for key in ("temperature", *NON_POLICY_SAMPLING)
            if key in body
        }
        return normalize_response(payload)

    async def list_models(self) -> dict[str, Any]:
        client = await self._get_client()
        try:
            response = await client.get("/models")
        except httpx.RequestError as exc:
            raise self._transport_error(exc) from exc
        await self._raise_for_status(response)
        return response.json()

    async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
        """POST with two independent recovery paths: transient status, and a rejected parameter.

        They are separate because they mean different things. A 429 means "the same request, later";
        a 400 naming a parameter means "a different request, now". Conflating them would either sleep
        through a permanent failure or hammer a provider that asked us to slow down.
        """
        for fix in self.param_fixes:
            fix.apply(body)

        while True:
            try:
                return await self._post_with_retries(path, body)
            except UpstreamHTTPError as exc:
                permission_for_logprobs = (
                    exc.status_code == 403
                    and "You are not allowed to request logprobs from this model"
                    in str(exc.body)
                )
                if (exc.status_code != 400 and not permission_for_logprobs) or len(
                    self.param_fixes
                ) >= MAX_FIXES:
                    raise
                fix = diagnose(exc.body)
                # `apply` returning False means the parameter it named is not in this body, so
                # retrying would send the identical request and get the identical 400.
                if fix is None or fix in self.param_fixes or not fix.apply(body):
                    raise
                self.param_fixes.append(fix)
                logger.info(
                    "upstream rejected a parameter; %s and retrying (this endpoint now carries "
                    "%d fix(es))",
                    fix,
                    len(self.param_fixes),
                )

    async def _post_with_retries(
        self, path: str, body: dict[str, Any], *, headers: dict[str, str] | None = None
    ) -> dict[str, Any]:
        client = await self._get_client()
        for attempt in range(1, self._MAX_ATTEMPTS + 1):
            try:
                response = await client.post(
                    path, json=body, **({"headers": headers} if headers else {})
                )
            except httpx.RequestError as exc:
                raise self._transport_error(exc) from exc
            if response.is_success:
                return response.json()

            retry_after = _retry_after_seconds(response)
            if (
                response.status_code not in self._RETRY_STATUSES
                or attempt == self._MAX_ATTEMPTS
            ):
                await self._raise_for_status(response)
            delay = (
                retry_after
                if retry_after is not None
                else self._BACKOFF_S * (2 ** (attempt - 1))
            )
            logger.info(
                "upstream returned %d; retrying in %.1fs (attempt %d/%d)",
                response.status_code,
                delay,
                attempt,
                self._MAX_ATTEMPTS,
            )
            await response.aclose()
            await asyncio.sleep(min(delay, self._MAX_RETRY_AFTER_S))
        raise AssertionError("unreachable: the final attempt always raises or returns")

    async def _raise_for_status(self, response: httpx.Response) -> None:
        if response.is_success:
            return
        content = await response.aread()
        await response.aclose()
        body: dict[str, Any] | str | None = None
        text = content.decode("utf-8", errors="replace").strip()
        if text:
            try:
                body = json.loads(text)
            except json.JSONDecodeError:
                body = text
        raise UpstreamHTTPError(response.status_code, body)

    @staticmethod
    def _transport_error(exc: httpx.RequestError) -> UpstreamError:
        if isinstance(exc, httpx.TimeoutException):
            return UpstreamTimeoutError(f"engine timed out: {exc}")
        return UpstreamTransportError(f"could not reach engine: {exc}")