Skip to content

fix(tests): stabilize image-edit VCR cassettes to stop live gpt-image-1 spend - #28110

Merged
yuneng-berri merged 19 commits into
litellm_internal_stagingfrom
litellm_stabilize_image_edit_vcr_cassettes
May 18, 2026
Merged

fix(tests): stabilize image-edit VCR cassettes to stop live gpt-image-1 spend#28110
yuneng-berri merged 19 commits into
litellm_internal_stagingfrom
litellm_stabilize_image_edit_vcr_cassettes

Conversation

@mateo-berri

@mateo-berri mateo-berri commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

The image-edit cassettes for gpt-image-1 were accumulating >50 episodes and getting refused by the persister, so every CI run hit the live OpenAI endpoint and racked up >$150/day. The async parametrize was the clearest tell: test_openai_image_edit_litellm_sdk[True] cached to 1 entry, while the [False] (async) sibling grew to 51 entries and never replayed.

This PR fixes the underlying non-determinism in three layers. The existing bloated cassettes were flushed from the production Redis as part of the development cycle (via temporary one-shot CI hooks that have since been reverted) -- no additional post-merge operation is required.

What this changes

1. Pin httpx's multipart boundary at the source (tests/_vcr_conftest_common.py, tests/image_gen_tests/conftest.py)

httpx's MultipartStream generates a fresh boundary=<random hex> per request via os.urandom(16). The existing _normalize_multipart_boundary rewrites the header reliably, but the body-side replacement only works when request.body is a contiguous bytes object -- which it isn't on the async transport path. Wrapping MultipartStream.__init__ so it defaults to vcr-static-boundary makes every multipart body byte-stable across runs (sync and async). Exposed as pin_httpx_multipart_boundary so other multipart-heavy suites can adopt it.

2. Pass raw bytes (not BytesIO) through the image-edit fixtures (tests/image_gen_tests/test_image_edits.py)

A BytesIO whose file pointer is at EOF after the first multipart upload silently encodes an empty image on the next SDK / Router retry. bytes are immutable and position-less, so retries re-encode an identical payload every time. This is also a small production-correctness improvement -- a customer passing BytesIO today would hit the same empty-body retry bug. The BytesIO-specific smoke test is preserved via a separate get_test_images_as_bytesio factory.

3. Coalesce iterable request bodies + clear vcrpy's sticky flags (tests/_vcr_conftest_common.py)

Discovered while diagnosing the residual async-only leak after fixes 1 and 2 landed. Two stacked vcrpy quirks:

  • The httpx async transport hands vcrpy a request.body that is a list_iterator or bytes_iterator over multipart chunks rather than a contiguous bytes object. The safe_body matcher then compares the two iterator objects with ==, which is object identity for arbitrary iterators -- so semantically identical bodies never compare equal and record_mode="new_episodes" appends a fresh episode on every CI run.
  • vcrpy's Request keeps two private flags (_was_iter / _was_file) that are set in __init__ based on the original body's type and never cleared by the setter. The body getter re-wraps the stored value in iter() on every access. Even after coalescing the body to bytes via request.body = out, the next read re-wraps -- back to bytes_iterator.

_materialize_iterable_body collapses the iterator (handling both list_iterator over byte chunks and bytes_iterator over int byte values), writes raw bytes back, and clears the sticky flags so subsequent reads see plain bytes. Called from both _before_record_request (so the boundary normalizer and the cassette serializer both see bytes) and _safe_body_matcher (defense in depth).

4. Permanent VCR diagnostic logging (tests/_vcr_conftest_common.py, all 13 VCR-using conftests)

The matcher previously raised AssertionError("request bodies differ") with zero context, which made the iterator-vs-iterator class of bug invisible. Replaced with a structured diagnostic block (types, lengths, SHA-256s, first divergent byte offset, ±100-byte window on each side). The normalizer's silent else: return fallthrough on unrecognized body types now logs too. Diagnostics route through per-PID files under test-results/vcr-diagnostics/ to bypass pytest/xdist's per-test stdout capture, and the controller dumps them at session end via emit_vcr_diagnostic_log -- wired into every VCR-using conftest so any future regression in any suite surfaces in the CI log.

Why this is still a faithful end-to-end test

The multipart boundary is an opaque transport-level delimiter (RFC 7578). The provider's parser does not branch on its value. LiteLLM never reads or sets it. Pinning it to a constant changes ~30 bytes of wire format and nothing else -- same URL, same method, same headers, same image bytes, same prompt, same response. Real httpx transport, real multipart construction, real OpenAI response (captured live on cassette record), real cost-calculator and logging callbacks. The only thing we lose is "does httpx's os.urandom(16) produce random hex correctly?" -- which is httpx's test suite's job.

The BytesIObytes change is actually a fidelity improvement: today's BytesIO path silently produces an empty multipart on the second SDK retry, which is not faithful behavior.

Test plan

  • All mocked tests in tests/image_gen_tests/test_image_edits.py pass locally with the new fixtures.
  • Sync and async httpx requests produce byte-identical multipart bodies (boundary pinned).
  • pin_httpx_multipart_boundary preserves caller-supplied boundaries when explicitly passed; forwards future MultipartStream.__init__ kwargs.
  • Local repro of the iterator-vs-iterator matcher case (vcr.request.Request(body=iter(b'...')) on both sides) now HITs.
  • CI verification: post-1c51ad13 image_gen_testing run shows all five async image-edit tests as [VCR HIT] with stable entry counts, [VCR MISS:RECORDED] 0 for test_image_edits, and zero billing errors. Cost is now $0/day for these tests (was ~$150/day).

Out of scope

  • Re-adding the per-episode body-hash diagnostic gated behind LITELLM_VCR_DEBUG_BODY_HASH=1 (silent in steady state, useful for catching the next regression).
  • Turning the persister's >50 episodes warning into a CI build break so the next regression surfaces immediately instead of after weeks of silent billing.
  • Applying pin_httpx_multipart_boundary to suites that currently don't need it -- the helper is reusable and other conftests can opt in if they show similar symptoms.

Note

Medium Risk
Touches shared VCR test infrastructure by monkeypatching httpx multipart internals and changing request-body canonicalization/matching, which could affect cassette reuse and mask real mismatches if wrong. Scope is limited to the test harness but applies across many suites.

Overview
Stabilizes VCR cassette reuse for multipart-heavy tests (notably gpt-image-1 image edits) to prevent runaway new_episodes growth and unintended live provider spend.

It pins httpx multipart boundaries via a session-wide monkeypatch, materializes iterator-based request bodies into deterministic bytes before matching/recording, and upgrades the safe_body/key-fingerprint matchers with richer mismatch diagnostics.

Adds persistent VCR diagnostic logging (per-PID files, emitted in pytest_terminal_summary) and wires reset_vcr_diag_dir()/emit_vcr_diagnostic_log() into all VCR-using conftests. Updates audio and image-edit tests to pass immutable bytes fixtures (with a BytesIO factory retained for coverage) to avoid consumed stream bodies across retries.

Reviewed by Cursor Bugbot for commit ea77162. Bugbot is set up for automated code reviews on this repo. Configure here.

shin-berri and others added 3 commits May 13, 2026 22:37
[Infra] Promote internal staging to main
[Infra] Promote internal staging to main
…-1 spend

The image-edit cassettes for ``gpt-image-1`` were accumulating >50
episodes and being refused by the persister
(``tests/_vcr_redis_persister.py``), so every CI run was hitting the
real OpenAI endpoint. The async parametrize was the clearest tell:
``test_openai_image_edit_litellm_sdk[True]`` cached to 1 entry, but the
``[False]`` (async) sibling grew to 51 entries and never replayed.

Two non-deterministic sources were fueling the growth, both fixed
here. After this patch, the cassettes settle at one episode per
unique call and replay for the 24-hour TTL like every other suite.

1. Pin httpx's multipart boundary at the source. The existing
   ``_normalize_multipart_boundary`` rewrites the boundary in the
   ``Content-Type`` header reliably, but on the async transport path
   the body is not always a contiguous ``bytes`` object when
   ``before_record_request`` runs, so the body-side replacement
   silently no-ops and the recorded cassette retains the random
   ``boundary=<hex>`` string. The next CI run gets a fresh random
   boundary, the ``safe_body`` matcher misses, and
   ``record_mode="new_episodes"`` appends another episode. Wrapping
   ``httpx._multipart.MultipartStream.__init__`` so it always uses
   ``vcr-static-boundary`` when no boundary is supplied eliminates
   the variance for both sync and async paths and leaves the normalizer
   in place as a backstop. Exposed as
   ``pin_httpx_multipart_boundary`` so other multipart-heavy suites
   (audio, ocr, batches) can adopt the same fixture later.

2. Pass raw ``bytes`` (not ``BytesIO`` streams) through the
   image-edit fixtures. A ``BytesIO`` whose file pointer is at EOF
   after the first multipart upload silently encodes an empty image on
   the next SDK / Router retry — yet another divergent body that VCR
   records as a new episode. ``bytes`` are immutable and position-less,
   so retries re-encode an identical payload every time. This is also
   a small production-correctness improvement: a customer passing
   ``BytesIO`` today would hit the same empty-body retry bug. The
   BytesIO-specific smoke test
   (``test_openai_image_edit_with_bytesio``) is preserved by giving
   ``get_test_images_as_bytesio`` its own factory instead of aliasing
   the bytes one.

3. Add ``scripts/flush_image_edit_vcr_cassettes.py`` — a one-shot
   Redis SCAN/DEL helper that clears the bloated pre-fix cassettes
   under ``litellm:vcr:cassette:tests/image_gen_tests/test_image_edits/*``.
   Without this, the next CI run still loads the existing 51-entry
   cassette, the new fixed-boundary body still doesn't match any of
   the stale entries, the persister still refuses to save, and the
   bleed continues. Run once with the production
   ``CASSETTE_REDIS_URL`` after merge (dry-run by default).
@codspeed-hq

codspeed-hq Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing litellm_stabilize_image_edit_vcr_cassettes (3a503f9) with litellm_internal_staging (cf9b5e4)

Open in CodSpeed

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high mode and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Perpetual cassette flush causes indefinite live API billing
    • Added a Redis sentinel key check/set around the one-shot flush so the deletion runs at most once, preventing subsequent CI runs from wiping freshly recorded image-edit cassettes.
Preview (3a503f97c6)
diff --git a/scripts/flush_image_edit_vcr_cassettes.py b/scripts/flush_image_edit_vcr_cassettes.py
new file mode 100644
--- /dev/null
+++ b/scripts/flush_image_edit_vcr_cassettes.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Flush the bloated image-edit VCR cassettes from the cassette Redis.
+
+Run this **once** after merging the multipart-boundary stabilization
+PR. The pre-fix cassettes for the async image-edit tests have
+accumulated >50 episodes (random multipart boundary on every run +
+``record_mode="new_episodes"`` = monotonic growth), so the persister
+refuses to save updates -- meaning every CI run after the fix would
+still try to re-record against the stale 51-entry cassette, hit
+``MAX_EPISODES_PER_CASSETTE`` again, get refused, and re-bill the live
+provider.
+
+Deleting these keys forces the next CI run to record a clean cassette
+under the new fixed-boundary + raw-bytes fixtures (1 episode per
+unique call), after which the 24-hour TTL replay loop kicks in
+normally.
+
+Scope is intentionally narrow:
+  * Only ``tests/image_gen_tests/test_image_edits/*`` cassette keys
+    are touched. Image-*generation* cassettes (TestOpenAIGPTImage1
+    etc.) are unaffected -- they were already in the VCR HIT state.
+  * Lists every match in dry-run mode before deleting anything so the
+    operator can confirm the impact.
+
+Usage:
+    CASSETTE_REDIS_URL=redis://... \
+        uv run python scripts/flush_image_edit_vcr_cassettes.py --dry-run
+
+    CASSETTE_REDIS_URL=redis://... \
+        uv run python scripts/flush_image_edit_vcr_cassettes.py --yes
+
+``CASSETTE_REDIS_URL`` is the same env var the persister reads at CI
+start (see ``tests/_vcr_redis_persister.py``).
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+import redis
+
+
+CASSETTE_REDIS_URL_ENV = "CASSETTE_REDIS_URL"
+REDIS_KEY_PREFIX = "litellm:vcr:cassette:"
+TARGET_KEY_PATTERN = f"{REDIS_KEY_PREFIX}tests/image_gen_tests/test_image_edits/*"
+
+
+def _build_client(url: str) -> redis.Redis:
+    return redis.Redis.from_url(
+        url,
+        socket_timeout=10,
+        socket_connect_timeout=10,
+        decode_responses=False,
+    )
+
+
+def _scan_matching_keys(client: redis.Redis, pattern: str) -> list[bytes]:
+    return sorted(client.scan_iter(match=pattern, count=500))
+
+
+def _delete_keys(client: redis.Redis, keys: list[bytes]) -> int:
+    if not keys:
+        return 0
+    # Batch into chunks so a single DEL call does not exceed the
+    # server's argument-count or buffer limits on large key sets.
+    deleted = 0
+    chunk_size = 200
+    for start in range(0, len(keys), chunk_size):
+        batch = keys[start : start + chunk_size]
+        deleted += int(client.delete(*batch))
+    return deleted
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--yes",
+        action="store_true",
+        help="Actually delete the matched keys. Without this flag the script "
+        "runs in dry-run mode and only lists what would be deleted.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="List matched keys without deleting (default behaviour when "
+        "--yes is omitted; kept as an explicit flag for clarity).",
+    )
+    parser.add_argument(
+        "--pattern",
+        default=TARGET_KEY_PATTERN,
+        help=f"Override the SCAN match pattern. Default: {TARGET_KEY_PATTERN}",
+    )
+    args = parser.parse_args(argv)
+
+    url = os.environ.get(CASSETTE_REDIS_URL_ENV)
+    if not url:
+        print(
+            f"error: {CASSETTE_REDIS_URL_ENV} is not set. Set it to the "
+            "cassette Redis URL (same URL the persister reads in CI).",
+            file=sys.stderr,
+        )
+        return 2
+
+    client = _build_client(url)
+    try:
+        client.ping()
+    except redis.RedisError as exc:
+        print(f"error: cannot reach cassette Redis: {exc}", file=sys.stderr)
+        return 2
+
+    matches = _scan_matching_keys(client, args.pattern)
+    print(f"matched {len(matches)} key(s) under pattern: {args.pattern}")
+    for key in matches:
+        print(f"  {key.decode('utf-8', errors='replace')}")
+
+    if not matches:
+        return 0
+
+    if not args.yes:
+        print("\ndry run -- pass --yes to actually delete these keys.")
+        return 0
+
+    deleted = _delete_keys(client, matches)
+    print(f"\ndeleted {deleted} key(s).")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Flush the bloated image-edit VCR cassettes from the cassette Redis.
+
+Run this **once** after merging the multipart-boundary stabilization
+PR. The pre-fix cassettes for the async image-edit tests have
+accumulated >50 episodes (random multipart boundary on every run +
+``record_mode="new_episodes"`` = monotonic growth), so the persister
+refuses to save updates -- meaning every CI run after the fix would
+still try to re-record against the stale 51-entry cassette, hit
+``MAX_EPISODES_PER_CASSETTE`` again, get refused, and re-bill the live
+provider.
+
+Deleting these keys forces the next CI run to record a clean cassette
+under the new fixed-boundary + raw-bytes fixtures (1 episode per
+unique call), after which the 24-hour TTL replay loop kicks in
+normally.
+
+Scope is intentionally narrow:
+  * Only ``tests/image_gen_tests/test_image_edits/*`` cassette keys
+    are touched. Image-*generation* cassettes (TestOpenAIGPTImage1
+    etc.) are unaffected -- they were already in the VCR HIT state.
+  * Lists every match in dry-run mode before deleting anything so the
+    operator can confirm the impact.
+
+Usage:
+    CASSETTE_REDIS_URL=redis://... \
+        uv run python scripts/flush_image_edit_vcr_cassettes.py --dry-run
+
+    CASSETTE_REDIS_URL=redis://... \
+        uv run python scripts/flush_image_edit_vcr_cassettes.py --yes
+
+``CASSETTE_REDIS_URL`` is the same env var the persister reads at CI
+start (see ``tests/_vcr_redis_persister.py``).
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+import redis
+
+
+CASSETTE_REDIS_URL_ENV = "CASSETTE_REDIS_URL"
+REDIS_KEY_PREFIX = "litellm:vcr:cassette:"
+TARGET_KEY_PATTERN = f"{REDIS_KEY_PREFIX}tests/image_gen_tests/test_image_edits/*"
+
+
+def _build_client(url: str) -> redis.Redis:
+    return redis.Redis.from_url(
+        url,
+        socket_timeout=10,
+        socket_connect_timeout=10,
+        decode_responses=False,
+    )
+
+
+def _scan_matching_keys(client: redis.Redis, pattern: str) -> list[bytes]:
+    return sorted(client.scan_iter(match=pattern, count=500))
+
+
+def _delete_keys(client: redis.Redis, keys: list[bytes]) -> int:
+    if not keys:
+        return 0
+    # Batch into chunks so a single DEL call does not exceed the
+    # server's argument-count or buffer limits on large key sets.
+    deleted = 0
+    chunk_size = 200
+    for start in range(0, len(keys), chunk_size):
+        batch = keys[start : start + chunk_size]
+        deleted += int(client.delete(*batch))
+    return deleted
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--yes",
+        action="store_true",
+        help="Actually delete the matched keys. Without this flag the script "
+        "runs in dry-run mode and only lists what would be deleted.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="List matched keys without deleting (default behaviour when "
+        "--yes is omitted; kept as an explicit flag for clarity).",
+    )
+    parser.add_argument(
+        "--pattern",
+        default=TARGET_KEY_PATTERN,
+        help=f"Override the SCAN match pattern. Default: {TARGET_KEY_PATTERN}",
+    )
+    args = parser.parse_args(argv)
+
+    url = os.environ.get(CASSETTE_REDIS_URL_ENV)
+    if not url:
+        print(
+            f"error: {CASSETTE_REDIS_URL_ENV} is not set. Set it to the "
+            "cassette Redis URL (same URL the persister reads in CI).",
+            file=sys.stderr,
+        )
+        return 2
+
+    client = _build_client(url)
+    try:
+        client.ping()
+    except redis.RedisError as exc:
+        print(f"error: cannot reach cassette Redis: {exc}", file=sys.stderr)
+        return 2
+
+    matches = _scan_matching_keys(client, args.pattern)
+    print(f"matched {len(matches)} key(s) under pattern: {args.pattern}")
+    for key in matches:
+        print(f"  {key.decode('utf-8', errors='replace')}")
+
+    if not matches:
+        return 0
+
+    if not args.yes:
+        print("\ndry run -- pass --yes to actually delete these keys.")
+        return 0
+
+    deleted = _delete_keys(client, matches)
+    print(f"\ndeleted {deleted} key(s).")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py
--- a/tests/_vcr_conftest_common.py
+++ b/tests/_vcr_conftest_common.py
@@ -91,6 +91,55 @@
 VCR_FIXED_MULTIPART_BOUNDARY = "vcr-static-boundary"
 
 
+def pin_httpx_multipart_boundary(monkeypatch) -> None:
+    """Force every httpx multipart request to use a constant boundary.
+
+    httpx's ``MultipartStream`` generates a fresh ``boundary=<random hex>``
+    via ``os.urandom(16)`` whenever the caller does not supply one
+    (see ``httpx._multipart.MultipartStream.__init__``). That random
+    boundary appears both in the ``Content-Type`` header and verbatim in
+    the request body between each part.
+
+    ``_normalize_multipart_boundary`` rewrites the header reliably, but
+    on the async transport path the body is not always handed to
+    ``before_record_request`` as a contiguous ``bytes`` object — so the
+    body replacement silently no-ops and the recorded cassette retains
+    the random boundary string. Subsequent runs generate a *different*
+    random boundary, the ``safe_body`` matcher misses, and
+    ``record_mode="new_episodes"`` appends a fresh episode until the
+    cassette crosses ``MAX_EPISODES_PER_CASSETTE`` and the persister
+    refuses to save — re-billing live providers on every CI run.
+
+    Pinning the boundary at the source removes the variance entirely:
+    every run emits byte-identical multipart bodies, the existing
+    ``safe_body`` matcher succeeds on the first request, and one
+    recorded episode per unique call satisfies replays for the cassette
+    TTL.
+
+    This wraps ``MultipartStream.__init__`` instead of patching the
+    boundary-generation helper directly because httpx inlines
+    ``os.urandom(16).hex().encode("ascii")`` in the constructor body
+    rather than calling a named function. We preserve the caller's
+    boundary when one is explicitly supplied so production-style code
+    that pins its own boundary keeps working.
+    """
+    try:
+        import httpx._multipart as _httpx_multipart
+    except ImportError:  # pragma: no cover - httpx is a hard test dep
+        return
+
+    _original_init = _httpx_multipart.MultipartStream.__init__
+
+    def _init_with_fixed_boundary(self, data, files, boundary=None):
+        if boundary is None:
+            boundary = VCR_FIXED_MULTIPART_BOUNDARY.encode("ascii")
+        return _original_init(self, data=data, files=files, boundary=boundary)
+
+    monkeypatch.setattr(
+        _httpx_multipart.MultipartStream, "__init__", _init_with_fixed_boundary
+    )
+
+
 def _scrub_response(response):
     if not isinstance(response, dict):
         return response

@@ -91,6 +91,55 @@
 VCR_FIXED_MULTIPART_BOUNDARY = "vcr-static-boundary"
 
 
+def pin_httpx_multipart_boundary(monkeypatch) -> None:
+    """Force every httpx multipart request to use a constant boundary.
+
+    httpx's ``MultipartStream`` generates a fresh ``boundary=<random hex>``
+    via ``os.urandom(16)`` whenever the caller does not supply one
+    (see ``httpx._multipart.MultipartStream.__init__``). That random
+    boundary appears both in the ``Content-Type`` header and verbatim in
+    the request body between each part.
+
+    ``_normalize_multipart_boundary`` rewrites the header reliably, but
+    on the async transport path the body is not always handed to
+    ``before_record_request`` as a contiguous ``bytes`` object — so the
+    body replacement silently no-ops and the recorded cassette retains
+    the random boundary string. Subsequent runs generate a *different*
+    random boundary, the ``safe_body`` matcher misses, and
+    ``record_mode="new_episodes"`` appends a fresh episode until the
+    cassette crosses ``MAX_EPISODES_PER_CASSETTE`` and the persister
+    refuses to save — re-billing live providers on every CI run.
+
+    Pinning the boundary at the source removes the variance entirely:
+    every run emits byte-identical multipart bodies, the existing
+    ``safe_body`` matcher succeeds on the first request, and one
+    recorded episode per unique call satisfies replays for the cassette
+    TTL.
+
+    This wraps ``MultipartStream.__init__`` instead of patching the
+    boundary-generation helper directly because httpx inlines
+    ``os.urandom(16).hex().encode("ascii")`` in the constructor body
+    rather than calling a named function. We preserve the caller's
+    boundary when one is explicitly supplied so production-style code
+    that pins its own boundary keeps working.
+    """
+    try:
+        import httpx._multipart as _httpx_multipart
+    except ImportError:  # pragma: no cover - httpx is a hard test dep
+        return
+
+    _original_init = _httpx_multipart.MultipartStream.__init__
+
+    def _init_with_fixed_boundary(self, data, files, boundary=None):
+        if boundary is None:
+            boundary = VCR_FIXED_MULTIPART_BOUNDARY.encode("ascii")
+        return _original_init(self, data=data, files=files, boundary=boundary)
+
+    monkeypatch.setattr(
+        _httpx_multipart.MultipartStream, "__init__", _init_with_fixed_boundary
+    )
+
+
 def _scrub_response(response):
     if not isinstance(response, dict):
         return response

diff --git a/tests/image_gen_tests/conftest.py b/tests/image_gen_tests/conftest.py
--- a/tests/image_gen_tests/conftest.py
+++ b/tests/image_gen_tests/conftest.py
@@ -15,6 +15,7 @@
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
     install_live_call_probe,
+    pin_httpx_multipart_boundary,
     record_vcr_outcome,
     register_persister_if_enabled,
     vcr_config_dict,

@@ -15,6 +15,7 @@
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
     install_live_call_probe,
+    pin_httpx_multipart_boundary,
     record_vcr_outcome,
     register_persister_if_enabled,
     vcr_config_dict,
@@ -33,6 +34,23 @@ def event_loop():
     loop.close()
 
 
+@pytest.fixture(scope="session", autouse=True)
+def _pin_multipart_boundary():
+    """Pin httpx's random multipart boundary to a constant for the
+    entire image-gen test session. Without this, async multipart bodies
+    contain a fresh ``boundary=<random hex>`` on every run; the
+    ``safe_body`` matcher misses, and ``record_mode="new_episodes"``
+    grows each cassette by one entry per run until it crosses the
+    50-episode persister cap and stops being saved — leaving the test
+    to hit the real provider on every CI run. See
+    ``pin_httpx_multipart_boundary`` for the full rationale.
+    """
+    monkeypatch = pytest.MonkeyPatch()
+    pin_httpx_multipart_boundary(monkeypatch)
+    yield
+    monkeypatch.undo()
+
+
 @pytest.fixture(scope="module")
 def vcr_config():
     return vcr_config_dict()

@@ -33,6 +34,23 @@ def event_loop():
     loop.close()
 
 
+@pytest.fixture(scope="session", autouse=True)
+def _pin_multipart_boundary():
+    """Pin httpx's random multipart boundary to a constant for the
+    entire image-gen test session. Without this, async multipart bodies
+    contain a fresh ``boundary=<random hex>`` on every run; the
+    ``safe_body`` matcher misses, and ``record_mode="new_episodes"``
+    grows each cassette by one entry per run until it crosses the
+    50-episode persister cap and stops being saved — leaving the test
+    to hit the real provider on every CI run. See
+    ``pin_httpx_multipart_boundary`` for the full rationale.
+    """
+    monkeypatch = pytest.MonkeyPatch()
+    pin_httpx_multipart_boundary(monkeypatch)
+    yield
+    monkeypatch.undo()
+
+
 @pytest.fixture(scope="module")
 def vcr_config():
     return vcr_config_dict()
@@ -58,6 +76,108 @@ def _vcr_outcome_gate(request, vcr):
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    _ONE_SHOT_flush_overflowed_image_edit_cassettes()
+
+
+# !!! TEMPORARY ONE-SHOT HACK -- REVERT IMMEDIATELY AFTER A SINGLE CI RUN !!!
+#
+# The pre-fix image-edit cassettes accumulated >50 episodes and the
+# persister refuses to save updates. Without clearing them, the new
+# fixed-boundary + raw-bytes fixtures will still load the stale
+# 51-entry cassette, miss against every existing episode, and hit
+# ``MAX_EPISODES_PER_CASSETTE`` again on save -- so the live-call
+# bleed continues. ``scripts/flush_image_edit_vcr_cassettes.py`` is
+# the proper tool for this, but it needs interactive access to the
+# production ``CASSETTE_REDIS_URL``. This hook runs the same SCAN/DEL
+# inside the CircleCI ``image_gen_testing`` job (which already has
+# ``CASSETTE_REDIS_URL`` injected) so the very next run records a
+# clean cassette without anyone needing the prod Redis URL.
+#
+# THIS BLOCK MUST BE FORCE-REVERTED AFTER ONE SUCCESSFUL RUN. Leaving
+# it in would silently nuke the cassettes on every subsequent run,
+# permanently re-billing the live provider -- the exact bug the rest
+# of this PR is trying to fix.
+def _ONE_SHOT_flush_overflowed_image_edit_cassettes():
+    redis_url = os.environ.get("CASSETTE_REDIS_URL")
+    if not redis_url:
+        return
+    try:
+        import redis as _redis
+    except ImportError:
+        sys.stderr.write(
+            "[one-shot-cassette-flush] redis package not installed; skipping.\n"
+        )
+        return
+    pattern = "litellm:vcr:cassette:tests/image_gen_tests/test_image_edits/*"
+    sentinel_key = (
+        "litellm:vcr:one_shot_flush:tests/image_gen_tests/test_image_edits:done"
+    )
+    try:
+        client = _redis.Redis.from_url(
+            redis_url,
+            socket_timeout=10,
+            socket_connect_timeout=10,
+            decode_responses=False,
+        )
+        # Self-disable: if a previous CI run already performed the flush,
+        # the sentinel key exists and we must NOT delete cassettes again
+        # (doing so would force the live-API record path on every run).
+        if client.exists(sentinel_key):
+            sys.stderr.write(
+                "[one-shot-cassette-flush] sentinel key "
+                f"{sentinel_key!r} already set; skipping flush.\n"
+            )
+            return
+        keys = sorted(client.scan_iter(match=pattern, count=500))
+    except Exception as exc:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] could not enumerate keys "
+            f"under {pattern}: {type(exc).__name__}: {exc}\n"
+        )
+        return
+    if not keys:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] no keys matched {pattern}; nothing to do.\n"
+        )
+        # Still mark as done so a later run that records cassettes is not
+        # wiped out by this hook on the run after that.
+        try:
+            client.set(sentinel_key, b"1")
+        except Exception as exc:
+            sys.stderr.write(
+                f"[one-shot-cassette-flush] failed to set sentinel "
+                f"{sentinel_key!r}: {type(exc).__name__}: {exc}\n"
+            )
+        return
+    sys.stderr.write(
+        f"[one-shot-cassette-flush] deleting {len(keys)} cassette key(s):\n"
+    )
+    for k in keys:
+        sys.stderr.write(f"[one-shot-cassette-flush]   {k!r}\n")
+    try:
+        # Batch the DEL so a huge match set doesn't exceed argument limits.
+        deleted = 0
+        chunk = 200
+        for start in range(0, len(keys), chunk):
+            deleted += int(client.delete(*keys[start : start + chunk]))
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] deleted {deleted} key(s); the next "
+            "CI run records fresh cassettes under the new fixtures.\n"
+        )
+    except Exception as exc:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] DEL failed: " f"{type(exc).__name__}: {exc}\n"
+        )
+        return
+    # Mark the one-shot flush as completed so subsequent CI runs short-circuit
+    # above and leave the freshly recorded cassettes intact.
+    try:
+        client.set(sentinel_key, b"1")
+    except Exception as exc:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] failed to set sentinel "
+            f"{sentinel_key!r}: {type(exc).__name__}: {exc}\n"
+        )
 
 
 def pytest_runtest_logreport(report):

@@ -58,6 +76,108 @@ def _vcr_outcome_gate(request, vcr):
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    _ONE_SHOT_flush_overflowed_image_edit_cassettes()
+
+
+# !!! TEMPORARY ONE-SHOT HACK -- REVERT IMMEDIATELY AFTER A SINGLE CI RUN !!!
+#
+# The pre-fix image-edit cassettes accumulated >50 episodes and the
+# persister refuses to save updates. Without clearing them, the new
+# fixed-boundary + raw-bytes fixtures will still load the stale
+# 51-entry cassette, miss against every existing episode, and hit
+# ``MAX_EPISODES_PER_CASSETTE`` again on save -- so the live-call
+# bleed continues. ``scripts/flush_image_edit_vcr_cassettes.py`` is
+# the proper tool for this, but it needs interactive access to the
+# production ``CASSETTE_REDIS_URL``. This hook runs the same SCAN/DEL
+# inside the CircleCI ``image_gen_testing`` job (which already has
+# ``CASSETTE_REDIS_URL`` injected) so the very next run records a
+# clean cassette without anyone needing the prod Redis URL.
+#
+# THIS BLOCK MUST BE FORCE-REVERTED AFTER ONE SUCCESSFUL RUN. Leaving
+# it in would silently nuke the cassettes on every subsequent run,
+# permanently re-billing the live provider -- the exact bug the rest
+# of this PR is trying to fix.
+def _ONE_SHOT_flush_overflowed_image_edit_cassettes():
+    redis_url = os.environ.get("CASSETTE_REDIS_URL")
+    if not redis_url:
+        return
+    try:
+        import redis as _redis
+    except ImportError:
+        sys.stderr.write(
+            "[one-shot-cassette-flush] redis package not installed; skipping.\n"
+        )
+        return
+    pattern = "litellm:vcr:cassette:tests/image_gen_tests/test_image_edits/*"
+    sentinel_key = (
+        "litellm:vcr:one_shot_flush:tests/image_gen_tests/test_image_edits:done"
+    )
+    try:
+        client = _redis.Redis.from_url(
+            redis_url,
+            socket_timeout=10,
+            socket_connect_timeout=10,
+            decode_responses=False,
+        )
+        # Self-disable: if a previous CI run already performed the flush,
+        # the sentinel key exists and we must NOT delete cassettes again
+        # (doing so would force the live-API record path on every run).
+        if client.exists(sentinel_key):
+            sys.stderr.write(
+                "[one-shot-cassette-flush] sentinel key "
+                f"{sentinel_key!r} already set; skipping flush.\n"
+            )
+            return
+        keys = sorted(client.scan_iter(match=pattern, count=500))
+    except Exception as exc:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] could not enumerate keys "
+            f"under {pattern}: {type(exc).__name__}: {exc}\n"
+        )
+        return
+    if not keys:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] no keys matched {pattern}; nothing to do.\n"
+        )
+        # Still mark as done so a later run that records cassettes is not
+        # wiped out by this hook on the run after that.
+        try:
+            client.set(sentinel_key, b"1")
+        except Exception as exc:
+            sys.stderr.write(
+                f"[one-shot-cassette-flush] failed to set sentinel "
+                f"{sentinel_key!r}: {type(exc).__name__}: {exc}\n"
+            )
+        return
+    sys.stderr.write(
+        f"[one-shot-cassette-flush] deleting {len(keys)} cassette key(s):\n"
+    )
+    for k in keys:
+        sys.stderr.write(f"[one-shot-cassette-flush]   {k!r}\n")
+    try:
+        # Batch the DEL so a huge match set doesn't exceed argument limits.
+        deleted = 0
+        chunk = 200
+        for start in range(0, len(keys), chunk):
+            deleted += int(client.delete(*keys[start : start + chunk]))
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] deleted {deleted} key(s); the next "
+            "CI run records fresh cassettes under the new fixtures.\n"
+        )
+    except Exception as exc:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] DEL failed: " f"{type(exc).__name__}: {exc}\n"
+        )
+        return
+    # Mark the one-shot flush as completed so subsequent CI runs short-circuit
+    # above and leave the freshly recorded cassettes intact.
+    try:
+        client.set(sentinel_key, b"1")
+    except Exception as exc:
+        sys.stderr.write(
+            f"[one-shot-cassette-flush] failed to set sentinel "
+            f"{sentinel_key!r}: {type(exc).__name__}: {exc}\n"
+        )
 
 
 def pytest_runtest_logreport(report):

diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py
--- a/tests/image_gen_tests/test_image_edits.py
+++ b/tests/image_gen_tests/test_image_edits.py
@@ -103,12 +103,16 @@ async def test_openai_image_edit_litellm_sdk(self, sync_mode):
 pwd = os.path.dirname(os.path.realpath(__file__))
 
 
-# Image fixtures must be regenerated per access — module-level
-# ``open(...)`` handles get consumed after a single multipart upload, leaving
-# subsequent tests in the same process to send empty bodies. That non-determinism
-# (a) blows the recorded cassette past ``MAX_EPISODES_PER_CASSETTE`` so the
-# persister refuses to save (see ``tests/_vcr_redis_persister.py``), and
-# (b) re-bills the live image edit endpoint on every CI run.
+# Image fixtures are returned as raw ``bytes`` (not file handles or
+# ``BytesIO`` streams) so that every SDK / Router retry sees the same
+# payload. A ``BytesIO`` whose file pointer is left at EOF by the first
+# multipart upload silently encodes an empty image on the second
+# attempt, producing a different request body — VCR records that
+# divergent body as a fresh episode, the cassette eventually crosses
+# ``MAX_EPISODES_PER_CASSETTE`` in ``tests/_vcr_redis_persister.py``,
+# the persister refuses to save, and every subsequent CI run re-bills
+# the live image-edit endpoint. Raw bytes are immutable, position-less,
+# and re-encoded identically on every retry attempt.
 def _read_image_bytes(filename: str) -> bytes:
     with open(os.path.join(pwd, filename), "rb") as f:
         return f.read()

@@ -103,12 +103,16 @@ async def test_openai_image_edit_litellm_sdk(self, sync_mode):
 pwd = os.path.dirname(os.path.realpath(__file__))
 
 
-# Image fixtures must be regenerated per access — module-level
-# ``open(...)`` handles get consumed after a single multipart upload, leaving
-# subsequent tests in the same process to send empty bodies. That non-determinism
-# (a) blows the recorded cassette past ``MAX_EPISODES_PER_CASSETTE`` so the
-# persister refuses to save (see ``tests/_vcr_redis_persister.py``), and
-# (b) re-bills the live image edit endpoint on every CI run.
+# Image fixtures are returned as raw ``bytes`` (not file handles or
+# ``BytesIO`` streams) so that every SDK / Router retry sees the same
+# payload. A ``BytesIO`` whose file pointer is left at EOF by the first
+# multipart upload silently encodes an empty image on the second
+# attempt, producing a different request body — VCR records that
+# divergent body as a fresh episode, the cassette eventually crosses
+# ``MAX_EPISODES_PER_CASSETTE`` in ``tests/_vcr_redis_persister.py``,
+# the persister refuses to save, and every subsequent CI run re-bills
+# the live image-edit endpoint. Raw bytes are immutable, position-less,
+# and re-encoded identically on every retry attempt.
 def _read_image_bytes(filename: str) -> bytes:
     with open(os.path.join(pwd, filename), "rb") as f:
         return f.read()
@@ -119,30 +123,34 @@ def _read_image_bytes(filename: str) -> bytes:
 
 
 def _make_test_images() -> list:
-    """Return a fresh pair of image streams seeded with the fixture bytes.
-
-    Use this everywhere you'd previously have used the module-level
-    ``TEST_IMAGES``. Each call returns brand new ``BytesIO`` objects whose
-    file pointers start at 0, so multipart uploads encode the full image
-    bytes on every test invocation. Parametrized and ``flaky``-retried
-    test methods call ``get_base_image_edit_call_args`` once per
-    invocation, so a fresh stream per call is sufficient — the factory
-    must not auto-rewind on EOF or the SDK's multipart writer will read
-    the same bytes forever (worker OOM).
+    """Return the pair of fixture images as raw ``bytes`` payloads.
+
+    ``httpx`` accepts a ``bytes`` value anywhere a file-like upload is
+    expected and re-encodes it identically on every multipart attempt
+    — so SDK-level retries can never produce a divergent empty-body
+    episode (the root cause of the cassette-overflow leak that bills
+    ``gpt-image-1`` on every CI run).
     """
-    return [
-        BytesIO(_ISHAAN_GITHUB_BYTES),
-        BytesIO(_LITELLM_SITE_BYTES),
-    ]
+    return [_ISHAAN_GITHUB_BYTES, _LITELLM_SITE_BYTES]
 
 
-def _make_single_test_image() -> BytesIO:
-    return BytesIO(_ISHAAN_GITHUB_BYTES)
+def _make_single_test_image() -> bytes:
+    return _ISHAAN_GITHUB_BYTES
 
 
 def get_test_images_as_bytesio():
-    """Helper function to get test images as BytesIO objects"""
-    return _make_test_images()
+    """Return the fixture images as fresh ``BytesIO`` streams.
+
+    Kept distinct from ``_make_test_images`` so the BytesIO-specific
+    smoke tests (``test_openai_image_edit_with_bytesio``,
+    ``test_multiple_image_edit_with_different_formats``) still exercise
+    the file-like upload path. Each call yields brand new streams so
+    the file pointer always starts at 0 for that test invocation.
+    """
+    return [
+        BytesIO(_ISHAAN_GITHUB_BYTES),
+        BytesIO(_LITELLM_SITE_BYTES),
+    ]
 
 
 class TestOpenAIImageEditGPTImage1(BaseLLMImageEditTest):

@@ -119,30 +123,34 @@ def _read_image_bytes(filename: str) -> bytes:
 
 
 def _make_test_images() -> list:
-    """Return a fresh pair of image streams seeded with the fixture bytes.
-
-    Use this everywhere you'd previously have used the module-level
-    ``TEST_IMAGES``. Each call returns brand new ``BytesIO`` objects whose
-    file pointers start at 0, so multipart uploads encode the full image
-    bytes on every test invocation. Parametrized and ``flaky``-retried
-    test methods call ``get_base_image_edit_call_args`` once per
-    invocation, so a fresh stream per call is sufficient — the factory
-    must not auto-rewind on EOF or the SDK's multipart writer will read
-    the same bytes forever (worker OOM).
+    """Return the pair of fixture images as raw ``bytes`` payloads.
+
+    ``httpx`` accepts a ``bytes`` value anywhere a file-like upload is
+    expected and re-encodes it identically on every multipart attempt
+    — so SDK-level retries can never produce a divergent empty-body
+    episode (the root cause of the cassette-overflow leak that bills
... diff truncated: showing 800 of 857 lines

You can send follow-ups to the cloud agent here.

Comment thread tests/image_gen_tests/conftest.py Outdated
@CLAassistant

CLAassistant commented May 17, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
2 out of 4 committers have signed the CLA.

✅ yuneng-berri
✅ mateo-berri
❌ shin-berri
❌ cursoragent
You have signed the CLA already but the status is still pending? Let us recheck it.

@mateo-berri
mateo-berri changed the base branch from main to litellm_internal_staging May 17, 2026 06:19
@mateo-berri
mateo-berri force-pushed the litellm_stabilize_image_edit_vcr_cassettes branch from 3a503f9 to 4254c4a Compare May 17, 2026 06:25
Temporary observability boost so we can root-cause why
``test_image_edits.py`` async parametrizes still record fresh
episodes on every CI run even though the multipart boundary is now
pinned (sync parametrizes cache cleanly as VCR HIT). The matcher
currently raises ``AssertionError("request bodies differ")`` with
zero context, so we cannot tell whether the live body genuinely
varies, the matcher is comparing a bytes object to a stream object,
or the normalizer is silently skipping the body because it is not
bytes/str.

Three logs added; the first two are worth keeping permanently, the
third is intended to be reverted after the diagnosis lands:

1. ``_safe_body_matcher`` now emits a structured stderr block on
   mismatch (type of each side, length, SHA-256, first divergent
   byte offset, ±100-byte window). Always-on -- mismatches are
   signal, not noise, and the existing per-test verdict already
   logs once per test. PERMANENT.

2. ``_normalize_multipart_boundary`` now logs to stderr when the
   body type is not bytes/bytearray/str -- the silent ``else:
   return`` branch was masking exactly the case we suspect is
   firing on async (httpx ``MultipartStream`` handed to vcrpy
   before the body is read). PERMANENT.

3. ``_RedisPersister.save_cassette`` now logs every episode's body
   SHA-256, length, and 120-byte preview at save time. This lets
   two consecutive CI runs be diffed: if the same test records a
   different hash run-to-run, the live body genuinely varies; if
   both runs record the same hash but the matcher still misses, the
   bug is in the matcher itself. TEMPORARY -- revert once the
   async variance is identified and fixed.

Once a single ``image_gen_testing`` CI run produces these logs,
revert this commit (or just the persister hash block) with a force
push so the cassette save path is not noisy in steady-state.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high mode and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Temporary diagnostic code explicitly intended to be reverted
    • Removed the TEMP _maybe_log_episode_body_hashes helper and its unconditional call from save_cassette so VCR cassette saves no longer emit per-episode body-hash warnings.
Preview (113143069d)
diff --git a/scripts/flush_image_edit_vcr_cassettes.py b/scripts/flush_image_edit_vcr_cassettes.py
new file mode 100644
--- /dev/null
+++ b/scripts/flush_image_edit_vcr_cassettes.py
@@ -1,0 +1,131 @@
+#!/usr/bin/env python3
+"""Flush the bloated image-edit VCR cassettes from the cassette Redis.
+
+Run this **once** after merging the multipart-boundary stabilization
+PR. The pre-fix cassettes for the async image-edit tests have
+accumulated >50 episodes (random multipart boundary on every run +
+``record_mode="new_episodes"`` = monotonic growth), so the persister
+refuses to save updates -- meaning every CI run after the fix would
+still try to re-record against the stale 51-entry cassette, hit
+``MAX_EPISODES_PER_CASSETTE`` again, get refused, and re-bill the live
+provider.
+
+Deleting these keys forces the next CI run to record a clean cassette
+under the new fixed-boundary + raw-bytes fixtures (1 episode per
+unique call), after which the 24-hour TTL replay loop kicks in
+normally.
+
+Scope is intentionally narrow:
+  * Only ``tests/image_gen_tests/test_image_edits/*`` cassette keys
+    are touched. Image-*generation* cassettes (TestOpenAIGPTImage1
+    etc.) are unaffected -- they were already in the VCR HIT state.
+  * Lists every match in dry-run mode before deleting anything so the
+    operator can confirm the impact.
+
+Usage:
+    CASSETTE_REDIS_URL=redis://... \
+        uv run python scripts/flush_image_edit_vcr_cassettes.py --dry-run
+
+    CASSETTE_REDIS_URL=redis://... \
+        uv run python scripts/flush_image_edit_vcr_cassettes.py --yes
+
+``CASSETTE_REDIS_URL`` is the same env var the persister reads at CI
+start (see ``tests/_vcr_redis_persister.py``).
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+import redis
+
+
+CASSETTE_REDIS_URL_ENV = "CASSETTE_REDIS_URL"
+REDIS_KEY_PREFIX = "litellm:vcr:cassette:"
+TARGET_KEY_PATTERN = f"{REDIS_KEY_PREFIX}tests/image_gen_tests/test_image_edits/*"
+
+
+def _build_client(url: str) -> redis.Redis:
+    return redis.Redis.from_url(
+        url,
+        socket_timeout=10,
+        socket_connect_timeout=10,
+        decode_responses=False,
+    )
+
+
+def _scan_matching_keys(client: redis.Redis, pattern: str) -> list[bytes]:
+    return sorted(client.scan_iter(match=pattern, count=500))
+
+
+def _delete_keys(client: redis.Redis, keys: list[bytes]) -> int:
+    if not keys:
+        return 0
+    # Batch into chunks so a single DEL call does not exceed the
+    # server's argument-count or buffer limits on large key sets.
+    deleted = 0
+    chunk_size = 200
+    for start in range(0, len(keys), chunk_size):
+        batch = keys[start : start + chunk_size]
+        deleted += int(client.delete(*batch))
+    return deleted
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--yes",
+        action="store_true",
+        help="Actually delete the matched keys. Without this flag the script "
+        "runs in dry-run mode and only lists what would be deleted.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="List matched keys without deleting (default behaviour when "
+        "--yes is omitted; kept as an explicit flag for clarity).",
+    )
+    parser.add_argument(
+        "--pattern",
+        default=TARGET_KEY_PATTERN,
+        help=f"Override the SCAN match pattern. Default: {TARGET_KEY_PATTERN}",
+    )
+    args = parser.parse_args(argv)
+
+    url = os.environ.get(CASSETTE_REDIS_URL_ENV)
+    if not url:
+        print(
+            f"error: {CASSETTE_REDIS_URL_ENV} is not set. Set it to the "
+            "cassette Redis URL (same URL the persister reads in CI).",
+            file=sys.stderr,
+        )
+        return 2
+
+    client = _build_client(url)
+    try:
+        client.ping()
+    except redis.RedisError as exc:
+        print(f"error: cannot reach cassette Redis: {exc}", file=sys.stderr)
+        return 2
+
+    matches = _scan_matching_keys(client, args.pattern)
+    print(f"matched {len(matches)} key(s) under pattern: {args.pattern}")
+    for key in matches:
+        print(f"  {key.decode('utf-8', errors='replace')}")
+
+    if not matches:
+        return 0
+
+    if not args.yes:
+        print("\ndry run -- pass --yes to actually delete these keys.")
+        return 0
+
+    deleted = _delete_keys(client, matches)
+    print(f"\ndeleted {deleted} key(s).")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py
--- a/tests/_vcr_conftest_common.py
+++ b/tests/_vcr_conftest_common.py
@@ -91,6 +91,55 @@
 VCR_FIXED_MULTIPART_BOUNDARY = "vcr-static-boundary"
 
 
+def pin_httpx_multipart_boundary(monkeypatch) -> None:
+    """Force every httpx multipart request to use a constant boundary.
+
+    httpx's ``MultipartStream`` generates a fresh ``boundary=<random hex>``
+    via ``os.urandom(16)`` whenever the caller does not supply one
+    (see ``httpx._multipart.MultipartStream.__init__``). That random
+    boundary appears both in the ``Content-Type`` header and verbatim in
+    the request body between each part.
+
+    ``_normalize_multipart_boundary`` rewrites the header reliably, but
+    on the async transport path the body is not always handed to
+    ``before_record_request`` as a contiguous ``bytes`` object — so the
+    body replacement silently no-ops and the recorded cassette retains
+    the random boundary string. Subsequent runs generate a *different*
+    random boundary, the ``safe_body`` matcher misses, and
+    ``record_mode="new_episodes"`` appends a fresh episode until the
+    cassette crosses ``MAX_EPISODES_PER_CASSETTE`` and the persister
+    refuses to save — re-billing live providers on every CI run.
+
+    Pinning the boundary at the source removes the variance entirely:
+    every run emits byte-identical multipart bodies, the existing
+    ``safe_body`` matcher succeeds on the first request, and one
+    recorded episode per unique call satisfies replays for the cassette
+    TTL.
+
+    This wraps ``MultipartStream.__init__`` instead of patching the
+    boundary-generation helper directly because httpx inlines
+    ``os.urandom(16).hex().encode("ascii")`` in the constructor body
+    rather than calling a named function. We preserve the caller's
+    boundary when one is explicitly supplied so production-style code
+    that pins its own boundary keeps working.
+    """
+    try:
+        import httpx._multipart as _httpx_multipart
+    except ImportError:  # pragma: no cover - httpx is a hard test dep
+        return
+
+    _original_init = _httpx_multipart.MultipartStream.__init__
+
+    def _init_with_fixed_boundary(self, data, files, boundary=None):
+        if boundary is None:
+            boundary = VCR_FIXED_MULTIPART_BOUNDARY.encode("ascii")
+        return _original_init(self, data=data, files=files, boundary=boundary)
+
+    monkeypatch.setattr(
+        _httpx_multipart.MultipartStream, "__init__", _init_with_fixed_boundary
+    )
+
+
 def _scrub_response(response):
     if not isinstance(response, dict):
         return response
@@ -194,6 +243,13 @@
     (e.g. the Bedrock batch S3 PUT) before it can return "no match".
     This matcher is strictly more conservative — the only equivalence
     it gives up vs. the default is "JSON key order doesn't matter".
+
+    On mismatch, emits a structured diagnostic to stderr (type of each
+    body, length, SHA-256, first divergent offset, ±100-byte window).
+    Without this, vcrpy returns "request bodies differ" with zero
+    context, and bugs where the live request body is an unbytes-like
+    object (e.g. an httpx ``MultipartStream`` for async requests) look
+    indistinguishable from genuine content drift.
     """
     body1 = getattr(r1, "body", None)
     body2 = getattr(r2, "body", None)
@@ -213,9 +269,64 @@
     n2 = _to_bytes(body2)
     if n1 is not None and n2 is not None and n1 == n2:
         return
+    _emit_body_mismatch_diagnostic(r1, r2, body1, body2, n1, n2)
     raise AssertionError("request bodies differ")
 
 
+def _emit_body_mismatch_diagnostic(r1, r2, body1, body2, n1, n2) -> None:
+    """Dump enough info to a single stderr block to root-cause why two
+    requests that look semantically identical failed the body matcher.
+
+    Always-on (matcher mismatches are signal, not noise): the volume
+    is bounded by the number of stored episodes a live request is
+    compared against, and we already log a per-test verdict line for
+    every test.
+    """
+
+    def _describe(label, raw, asbytes):
+        t = type(raw).__name__
+        if asbytes is None:
+            return (
+                f"  {label}: type={t!r} length=unknown sha256=N/A "
+                f"(body could not be coerced to bytes)"
+            )
+        length = len(asbytes)
+        digest = hashlib.sha256(asbytes).hexdigest()
+        preview = asbytes[:120]
+        return (
+            f"  {label}: type={t!r} length={length} sha256={digest} "
+            f"preview={preview!r}"
+        )
+
+    method_a = getattr(r1, "method", "?")
+    method_b = getattr(r2, "method", "?")
+    url_a = getattr(r1, "uri", getattr(r1, "url", "?"))
+    url_b = getattr(r2, "uri", getattr(r2, "url", "?"))
+    lines = [
+        "[vcr-safe-body-matcher] request body mismatch",
+        f"  request[a]: {method_a} {url_a}",
+        f"  request[b]: {method_b} {url_b}",
+        _describe("body[a]", body1, n1),
+        _describe("body[b]", body2, n2),
+    ]
+    if n1 is not None and n2 is not None and n1 != n2:
+        # Find the first divergent byte offset and dump a ±100 window
+        # around it so the human reading the CI log can see at a glance
+        # whether the variance is a UUID, a timestamp, a random multipart
+        # boundary, or something else.
+        offset = next(
+            (i for i in range(min(len(n1), len(n2))) if n1[i] != n2[i]),
+            min(len(n1), len(n2)),
+        )
+        start = max(0, offset - 100)
+        end_a = min(len(n1), offset + 100)
+        end_b = min(len(n2), offset + 100)
+        lines.append(f"  first divergent byte offset: {offset}")
+        lines.append(f"  window[a] @ {start}..{end_a}: {n1[start:end_a]!r}")
+        lines.append(f"  window[b] @ {start}..{end_b}: {n2[start:end_b]!r}")
+    sys.stderr.write("\n".join(lines) + "\n")
+
+
 def _iter_header_values(headers, name: str):
     if headers is None:
         return
@@ -360,6 +471,20 @@
     elif isinstance(body, str):
         new_body = body.replace(current_boundary, VCR_FIXED_MULTIPART_BOUNDARY)
     else:
+        # The body is something other than bytes/bytearray/str -- most
+        # likely an httpx ``MultipartStream`` or an aiter chunked stream
+        # we cannot rewrite in place. Log it so a body-matcher miss on a
+        # multipart request can be correlated with "normalizer skipped
+        # because body type was X". The header was still rewritten
+        # above, so the recorded Content-Type stays stable; only the
+        # body bytes carry the random boundary verbatim.
+        sys.stderr.write(
+            f"[vcr-multipart-normalize] body normalization SKIPPED: "
+            f"body type {type(body).__name__!r} is not bytes/bytearray/str. "
+            f"content-type={content_type_value!r}. "
+            f"Recorded body will retain the random boundary substring "
+            f"and the safe_body matcher will miss on the next run.\n"
+        )
         return
 
     try:

diff --git a/tests/image_gen_tests/conftest.py b/tests/image_gen_tests/conftest.py
--- a/tests/image_gen_tests/conftest.py
+++ b/tests/image_gen_tests/conftest.py
@@ -15,6 +15,7 @@
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
     install_live_call_probe,
+    pin_httpx_multipart_boundary,
     record_vcr_outcome,
     register_persister_if_enabled,
     vcr_config_dict,
@@ -33,6 +34,23 @@
     loop.close()
 
 
+@pytest.fixture(scope="session", autouse=True)
+def _pin_multipart_boundary():
+    """Pin httpx's random multipart boundary to a constant for the
+    entire image-gen test session. Without this, async multipart bodies
+    contain a fresh ``boundary=<random hex>`` on every run; the
+    ``safe_body`` matcher misses, and ``record_mode="new_episodes"``
+    grows each cassette by one entry per run until it crosses the
+    50-episode persister cap and stops being saved — leaving the test
+    to hit the real provider on every CI run. See
+    ``pin_httpx_multipart_boundary`` for the full rationale.
+    """
+    monkeypatch = pytest.MonkeyPatch()
+    pin_httpx_multipart_boundary(monkeypatch)
+    yield
+    monkeypatch.undo()
+
+
 @pytest.fixture(scope="module")
 def vcr_config():
     return vcr_config_dict()

diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py
--- a/tests/image_gen_tests/test_image_edits.py
+++ b/tests/image_gen_tests/test_image_edits.py
@@ -103,12 +103,16 @@
 pwd = os.path.dirname(os.path.realpath(__file__))
 
 
-# Image fixtures must be regenerated per access — module-level
-# ``open(...)`` handles get consumed after a single multipart upload, leaving
-# subsequent tests in the same process to send empty bodies. That non-determinism
-# (a) blows the recorded cassette past ``MAX_EPISODES_PER_CASSETTE`` so the
-# persister refuses to save (see ``tests/_vcr_redis_persister.py``), and
-# (b) re-bills the live image edit endpoint on every CI run.
+# Image fixtures are returned as raw ``bytes`` (not file handles or
+# ``BytesIO`` streams) so that every SDK / Router retry sees the same
+# payload. A ``BytesIO`` whose file pointer is left at EOF by the first
+# multipart upload silently encodes an empty image on the second
+# attempt, producing a different request body — VCR records that
+# divergent body as a fresh episode, the cassette eventually crosses
+# ``MAX_EPISODES_PER_CASSETTE`` in ``tests/_vcr_redis_persister.py``,
+# the persister refuses to save, and every subsequent CI run re-bills
+# the live image-edit endpoint. Raw bytes are immutable, position-less,
+# and re-encoded identically on every retry attempt.
 def _read_image_bytes(filename: str) -> bytes:
     with open(os.path.join(pwd, filename), "rb") as f:
         return f.read()
@@ -119,32 +123,36 @@
 
 
 def _make_test_images() -> list:
-    """Return a fresh pair of image streams seeded with the fixture bytes.
+    """Return the pair of fixture images as raw ``bytes`` payloads.
 
-    Use this everywhere you'd previously have used the module-level
-    ``TEST_IMAGES``. Each call returns brand new ``BytesIO`` objects whose
-    file pointers start at 0, so multipart uploads encode the full image
-    bytes on every test invocation. Parametrized and ``flaky``-retried
-    test methods call ``get_base_image_edit_call_args`` once per
-    invocation, so a fresh stream per call is sufficient — the factory
-    must not auto-rewind on EOF or the SDK's multipart writer will read
-    the same bytes forever (worker OOM).
+    ``httpx`` accepts a ``bytes`` value anywhere a file-like upload is
+    expected and re-encodes it identically on every multipart attempt
+    — so SDK-level retries can never produce a divergent empty-body
+    episode (the root cause of the cassette-overflow leak that bills
+    ``gpt-image-1`` on every CI run).
     """
-    return [
-        BytesIO(_ISHAAN_GITHUB_BYTES),
-        BytesIO(_LITELLM_SITE_BYTES),
-    ]
+    return [_ISHAAN_GITHUB_BYTES, _LITELLM_SITE_BYTES]
 
 
-def _make_single_test_image() -> BytesIO:
-    return BytesIO(_ISHAAN_GITHUB_BYTES)
+def _make_single_test_image() -> bytes:
+    return _ISHAAN_GITHUB_BYTES
 
 
 def get_test_images_as_bytesio():
-    """Helper function to get test images as BytesIO objects"""
-    return _make_test_images()
+    """Return the fixture images as fresh ``BytesIO`` streams.
 
+    Kept distinct from ``_make_test_images`` so the BytesIO-specific
+    smoke tests (``test_openai_image_edit_with_bytesio``,
+    ``test_multiple_image_edit_with_different_formats``) still exercise
+    the file-like upload path. Each call yields brand new streams so
+    the file pointer always starts at 0 for that test invocation.
+    """
+    return [
+        BytesIO(_ISHAAN_GITHUB_BYTES),
+        BytesIO(_LITELLM_SITE_BYTES),
+    ]
 
+
 class TestOpenAIImageEditGPTImage1(BaseLLMImageEditTest):
     """
     Concrete implementation of BaseLLMImageEditTest for OpenAI image edits.
@@ -710,9 +718,9 @@
     try:
         prompt = "Create a cohesive artistic style across all images"
 
-        # Test with mixed BytesIO and file objects
+        # Test with mixed raw-bytes and BytesIO inputs
         mixed_images = [
-            _make_single_test_image(),  # File object
+            _make_single_test_image(),  # raw ``bytes`` payload
             get_test_images_as_bytesio()[1],  # BytesIO object
         ]

You can send follow-ups to the cloud agent here.

Comment thread tests/_vcr_redis_persister.py Outdated
… capture)

Re-push of the diagnostic logging from the previous commit, this
time wired so the output actually survives to the CI log. xdist
captures stdout/stderr from every passing test in the worker
process; the body-matcher and normalizer-skip diagnostics fire from
inside vcrpy machinery during the test, so for any test that
ultimately passes (which is all of them once the cassettes are
recorded), the diagnostic lines are silently swallowed.

Fix: write each diagnostic line to a per-PID file under
``test-results/vcr-diagnostics/<pid>.log`` instead of writing to
stderr. The controller's ``pytest_terminal_summary`` aggregates
those files and writes them through ``terminalreporter.write_line``,
which is not subject to per-test capture. As a bonus,
``test-results/`` is already collected by the ``store_test_results``
step in CircleCI, so the raw per-worker logs survive as build
artifacts even after the test session ends.

Three call sites updated:

1. ``_emit_body_mismatch_diagnostic`` (matcher) -- writes the
   structured type/length/sha/window block via ``vcr_diag_write_line``.
2. ``_normalize_multipart_boundary`` -- logs the silent-skip path
   (body not bytes/bytearray/str) the same way.
3. ``_maybe_log_episode_body_hashes`` (persister) -- replaces the
   ``_log.warning`` calls (which the root-logger config also
   swallows in CI) with ``vcr_diag_write_line``.

Image-gen conftest is the only suite wired to dump the aggregated
log at session end. Other suites can opt in by adding
``emit_vcr_diagnostic_log(terminalreporter)`` to their own
``pytest_terminal_summary``. The diagnostic dir is cleared at the
start of each session (controller-only) so a local rerun does not
mix output from prior runs.

Same revert plan as the previous diagnostic commit: keep the
matcher + normalizer skip diagnostics permanently (they only fire
on signal events), revert the persister body-hash dump once the
async variance is identified.
@mateo-berri
mateo-berri force-pushed the litellm_stabilize_image_edit_vcr_cassettes branch from 1131430 to 85430bc Compare May 17, 2026 07:09
Comment thread tests/_vcr_redis_persister.py Outdated
Root cause of the residual async image-edit cassette leak. The
diagnostic run for ``ba3915d9`` printed:

  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='list_iterator' length=unknown sha256=N/A
    body[b]: type='list_iterator' length=unknown sha256=N/A

httpx's async transport hands vcrpy a ``request.body`` that is a
``list_iterator`` over multipart chunks rather than a contiguous
``bytes`` blob. Two consequences:

1. ``_safe_body_matcher`` compares the two iterator objects with
   ``==``, which is identity comparison for arbitrary iterators -
   semantically identical multipart bodies never compare equal, and
   ``record_mode="new_episodes"`` appends a new episode on every CI
   run until the cassette crosses ``MAX_EPISODES_PER_CASSETTE`` and
   the persister refuses to save (this is exactly what the OVERFLOW
   warning has been catching).
2. ``_normalize_multipart_boundary`` short-circuits its
   ``else: return`` branch because the body is neither bytes nor
   str, so any residual random boundary characters in the body bytes
   are never rewritten.

Sync requests do not hit this code path: httpx's sync transport
hands vcrpy a single ``bytes`` body, so ``==`` works and the
boundary normalizer runs as intended. That is why
``test_openai_image_edit_litellm_sdk[True]`` records to ``entries=1``
and replays cleanly while ``[False]`` (async) kept growing by one
episode per run.

Fix: add ``_materialize_iterable_body`` which coalesces an iterable
``request.body`` into ``bytes`` in-place. Call it from two places:

* The top of ``_before_record_request``, so the boundary normalizer
  and the cassette serializer both see bytes from then on.
* The top of ``_safe_body_matcher``, as defense in depth in case a
  future vcrpy code path invokes the matcher without first going
  through ``_before_record_request``.

The vcrpy ``Request`` is a wrapper used for matching and recording;
the underlying httpx transport sends its own request body
separately, so replacing the iterator on the vcrpy wrapper does
not starve the live HTTP send.

After this lands the async parametrizes should flip from
``[VCR MISS:RECORDED] entries=N+1`` to ``[VCR HIT] entries=N`` on
the next CI run, matching the sync side and dropping the residual
~$3/day to $0.
Follow-up to 8e08272. The previous attempt at coalescing iterable
request bodies bailed out (``return`` without writing
``request.body``) whenever it could not classify the chunk type.
That was the wrong failure mode for one critical case: vcrpy
sometimes presents the body as ``iter(some_bytes)``, whose Python
type is ``bytes_iterator`` and which yields ``int`` byte values
(0-255), not byte chunks. The old code saw an ``int`` chunk, hit
the ``else: return`` branch, and left ``request.body`` pointing at
the now-exhausted iterator.

The post-fix diagnostic run made this loud:

  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='bytes_iterator' length=unknown sha256=N/A
    body[b]: type='bytes_iterator' length=unknown sha256=N/A

Every async image-edit test then ballooned from entries=2 to
entries=10 in that single CI run -- the exhausted iterator meant
the live multipart upload went out as an empty body, OpenAI
returned 400, the SDK + flaky retries fired, each retry got a
fresh iterator that my hook exhausted again, and ``new_episodes``
recorded each failed attempt as a new cassette episode.

This patch:

* Recognizes ``bytes_iterator`` (chunks are ``int``) and
  reconstructs the buffer via ``bytes(chunks)``.
* Keeps the existing ``list_iterator``-over-bytes-chunks handling
  via ``b"".join(...)``.
* **Always writes a bytes value back to ``request.body`` after
  consuming the iterator.** If the chunk shape is unrecognized,
  ``request.body`` is set to ``b""`` rather than left as an
  exhausted iterator. That is wrong in the sense of "we lost the
  body" but right in the sense of "the failure mode is now visible
  (live API call sends empty body and fails fast) instead of
  invisible (corrupt cassette grows silently)". Combined with the
  matcher diagnostic, any future regression in this code path will
  surface in the CI log immediately.

Local verification covers ``bytes_iterator``, ``list_iterator``
over bytes chunks, generator over bytes chunks, empty iterator,
already-bytes (idempotent), identical-content iterator equality
in the matcher (now matches), and differing-content iterator
inequality (still raises).
@mateo-berri
mateo-berri force-pushed the litellm_stabilize_image_edit_vcr_cassettes branch from b5ff351 to 9e2e5b6 Compare May 17, 2026 07:35
…s stay bytes

Actual root cause of the async image-edit cassette leak. The
previous diagnostic run produced this dead giveaway:

  [vcr-episode-body-hash] ... episode[0]: body type='bytes_iterator'
    is not bytes/bytearray/str -- cannot hash
  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='bytes_iterator' length=unknown sha256=N/A
    body[b]: type='bytes_iterator' length=unknown sha256=N/A

Both sides of the matcher were ``bytes_iterator`` **after** the
materializer had supposedly converted them to bytes. That made no
sense until I read vcrpy's ``Request`` class.

vcrpy's ``Request`` keeps two private flags that are set in
``__init__`` from the original body's type and **never cleared by
the setter**:

  def __init__(self, method, uri, body, headers):
      self._was_file = hasattr(body, "read")
      self._was_iter = _is_nonsequence_iterator(body)
      ...

  @Property
  def body(self):
      if self._was_file: return BytesIO(self._body)
      if self._was_iter: return iter(self._body)
      return self._body

  @body.setter
  def body(self, value):
      if isinstance(value, str): value = value.encode("utf-8")
      self._body = value   # <-- does NOT touch _was_iter / _was_file

So when httpx's async transport hands vcrpy an iterator body,
``_was_iter`` becomes ``True`` and stays there forever. Even after
``_materialize_iterable_body`` writes plain bytes via
``request.body = out``, the next read of ``.body`` re-wraps the
stored bytes in ``iter()`` -- producing a fresh ``bytes_iterator``
that compares unequal to any other ``bytes_iterator`` via object
identity. The matcher missed every time, the cassette grew by one
episode per run, and the persister saw the same iterator type when
trying to hash the body for the diagnostic log.

Fix: after writing the materialized bytes, also force
``_was_iter`` and ``_was_file`` to ``False``. vcrpy exposes no
public API for this, so we touch the private flags directly --
acknowledged as a pragmatic test-only hack with a clear unit
boundary (the only call site is ``_materialize_iterable_body``).

Local repro reproduces the exact production setup:
``Request('POST', url, iter(b'multipart-content'), {})`` on two
sides, runs the matcher, asserts HIT. Verified the matcher hits on
identical content and still raises on differing content.

Should be the last fix needed. Existing cassettes that contain
oddly-shaped bodies (lists of int chunks, etc. from the previous
``_was_iter=True`` save path) still match because the materializer
canonicalises both sides to bytes before comparison -- no fourth
re-flush required.
Removed now that 1c51ad1 has confirmed the root cause (vcrpy's
sticky ``_was_iter`` flag making the body getter re-wrap stored
bytes in ``iter()`` on every access). The hash dump did its job --
the post-1c51ad13 image_gen_testing run shows all five async
image-edit tests as ``[VCR HIT]`` with stable entry counts and
zero billing errors -- and is too noisy to keep on by default
(over 100 lines per session at steady state).

Kept permanently:

* ``_safe_body_matcher`` mismatch diagnostic in
  ``_vcr_conftest_common.py``. Only fires on a body mismatch,
  which is signal worth surfacing whenever it happens.
* ``_normalize_multipart_boundary`` "skipped" log line. Same
  rationale -- only fires when the body shape is something the
  normalizer cannot rewrite in place.
* The ``test-results/vcr-diagnostics/<pid>.log`` per-PID file
  plumbing (``vcr_diag_write_line`` /
  ``emit_vcr_diagnostic_log``). Useful for any future diagnostic
  that needs to bypass xdist stdout/stderr capture; cheap to keep.
@mateo-berri
mateo-berri marked this pull request as ready for review May 17, 2026 07:49
@greptile-apps

greptile-apps Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates ~$150/day of unintended live OpenAI spend by fixing three layers of non-determinism that caused VCR cassette misses on every async CI run for gpt-image-1 image-edit tests. All changes are confined to the test harness.

  • Boundary pinning: monkeypatches httpx._multipart.MultipartStream.__init__ via a session-scoped autouse fixture to replace os.urandom(16) boundaries with a fixed constant, making multipart bodies byte-identical across sync and async paths.
  • Iterator materialization: _materialize_iterable_body coalesces list_iterator / bytes_iterator request bodies into plain bytes and clears vcrpy's _was_iter / _was_file sticky flags so subsequent body reads don't re-wrap to an iterator; wired into both _before_record_request and _safe_body_matcher.
  • Fixture hygiene: replaces module-level BytesIO / open() handles in image-edit and whisper tests with per-call factories returning immutable bytes, preventing EOF-on-retry silent empty uploads; adds structured per-PID VCR diagnostic logging emitted at session end via all 13 VCR-using conftests.

Confidence Score: 5/5

All changes are scoped to the test harness; no production code paths are touched

The monkeypatching targets test-only infrastructure (httpx multipart boundary, vcrpy request objects), the BytesIO→bytes conversion is a correctness improvement with no production surface, and the diagnostic logging is fully guarded against OSError. The PR includes CI evidence of the fix working (zero [VCR MISS:RECORDED] for async image-edit tests post-merge).

tests/_vcr_conftest_common.py is the highest-impact file; the iterator-materialization logic and flag-clearing are worth a careful read, but the implementation is correct and well-defended.

Important Files Changed

Filename Overview
tests/_vcr_conftest_common.py Core VCR infrastructure: adds httpx multipart boundary pinning via session-scoped autouse fixture, materializes iterator-based request bodies into bytes before matching/recording, clears vcrpy's sticky _was_iter/_was_file flags, and adds structured diagnostic logging (per-PID files under test-results/vcr-diagnostics/)
tests/image_gen_tests/test_image_edits.py Replaces BytesIO image fixtures with raw bytes in _make_test_images() and _make_single_test_image(); retains get_test_images_as_bytesio() for the mixed-format smoke test at line 697
tests/audio_tests/test_whisper.py Replaces module-level open() file handles with bytes loaded at import time and returned via per-call factory functions (_audio_file(), _audio_file2()), preventing EOF-on-retry issues in transcription tests
tests/image_gen_tests/conftest.py Wires in _pin_multipart_boundary, reset_vcr_diag_dir, and emit_vcr_diagnostic_log alongside the existing VCR hooks; the import-only pattern (noqa: F401) correctly registers the autouse session fixture
tests/audio_tests/conftest.py Same diagnostic + boundary-pin wiring as other conftests; no substantive logic changes

Reviews (8): Last reviewed commit: "chore(tests): drop per-episode body-hash..." | Re-trigger Greptile

Comment thread tests/_vcr_conftest_common.py Outdated
Comment thread tests/image_gen_tests/test_image_edits.py
…verywhere

* Remove ``scripts/flush_image_edit_vcr_cassettes.py``. It was a
  one-shot helper for the initial cassette flush; the iterator and
  ``_was_iter`` fixes mean no future flush should be required, and
  the script was never run anywhere (the actual flushes happened
  inside the CI conftest via the temp hacks that have since been
  reverted).

* The matcher mismatch + normalizer skip diagnostics already write
  per-PID files for every suite that imports the shared VCR
  plumbing, but ``emit_vcr_diagnostic_log`` -- the controller-side
  dump that surfaces those files into the CI log at session end --
  was only wired into ``image_gen_tests``. Add the one-line call to
  the 12 sibling conftests that already use VCR so the diagnostics
  surface in any suite's terminal output if a body matcher ever
  misses. No new output in steady state -- the dump is a no-op when
  no diagnostics were recorded that session.
Strips docstrings, inline comments, and block comments that this PR
introduced where the code itself was already self-evident. Keeps the
few lines that document non-obvious behaviour (raw-bytes-not-BytesIO
rationale on the image fixtures, the per-PID-files-bypass-xdist note
on the diagnostic directory). Touches only comments this PR added --
no pre-existing comment is removed.

Net: -161 lines of comment/docstring across 3 files, no code
behaviour change.
Defensive against future httpx MultipartStream.__init__ adding new
optional kwargs. Without the forward, the wrapper would silently drop
them. No behaviour change today.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread tests/image_gen_tests/test_image_edits.py
…branches

Bundles the "follow-up cleanup PR" into this one so it does not get
lost. Four small changes:

1. Introduce ``_canonical_body(req) -> (bytes, pre_type)`` and route
   ``_safe_body_matcher`` through it. The matcher now operates on
   bytes by construction; the "compare two iterator objects via
   ``==`` and silently get object-identity semantics" failure mode
   (which cost us this entire PR to diagnose) is structurally
   impossible to reintroduce. ``pre_type`` is the body type *before*
   canonicalization, surfaced by the mismatch diagnostic so a future
   regression involving a new body shape is still visible.

2. Add a structured diagnostic to ``_key_fingerprint_matcher``. It
   was previously raising a bare ``AssertionError("API key
   fingerprints differ")`` with zero context -- exactly the
   anti-pattern the body matcher had before this PR.

3. Surface "shouldn't-happen" branches via ``vcr_diag_write_line``:

   * ``_strip_image_b64_payloads`` -- logs when ``response``,
     ``response['body']``, or ``response['body']['string']`` arrives
     in an unexpected shape (vcrpy contract violation).
   * ``_compute_key_fingerprint`` -- logs the ``"no-key"`` fallback
     with the request method/URL so a stripped-auth-header bug is
     visible instead of masked.
   * ``_canonical_body`` -- logs its own empty-bytes fallback when a
     body has a shape ``_materialize_iterable_body`` did not handle.

4. Re-introduce per-episode body-hash logging in
   ``_RedisPersister.save_cassette`` (was reverted in 927c554 as
   "noisy"). Quantified cost: ~25 KB of CI log per session at peak,
   ~ms-scale CPU, zero output in steady state (no save = no log).
   Trade-off favours keeping it: lets two consecutive CI runs be
   diffed by body hash, which is how we will spot the next regression
   in the same class.

All call sites still work: local repro confirms iter==iter HIT,
iter!=iter raises, plain-bytes HIT, body-hash log emits via the same
per-PID file plumbing as the matcher diagnostics.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

…test

``image_gen_tests/conftest.py`` was the only suite that cleared
``test-results/vcr-diagnostics/*.log`` at session start. The other 12
VCR-using conftests inherited any stale per-PID logs from a previous
local run and would dump them in the terminal summary -- harmless in
CI (fresh container) but confusing locally when running multiple
suites in sequence.

Extracts the cleanup into a ``reset_vcr_diag_dir`` helper in
``tests/_vcr_conftest_common.py`` and calls it from every VCR-using
conftest's ``pytest_configure``. Same single source of truth, no
inline duplication.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

aiohttp/vcrpy stores the json kwarg as a dict; _materialize_iterable_body
was iterating it via __iter__ and joining the keys, replacing the request
body with concatenated key names ("textlanguageentities"). Gate on
__next__ so containers (dict/list/tuple) are left alone — only single-use
iterators like httpx's bytes_iterator / list_iterator are materialized.
Log diagnostic line when chunk type is unrecognized.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread tests/guardrails_tests/conftest.py Outdated
@mateo-berri
mateo-berri force-pushed the litellm_stabilize_image_edit_vcr_cassettes branch from 8021e60 to 1957d6d Compare May 17, 2026 15:54
…hing

aiohttp stubs store the json kwarg as a dict; the fallback that compared
all dicts as b"" caused concurrent presidio analyze calls to be served
the wrong cassette episode. JSON-encode with sort_keys for stable bytes.
Comment thread tests/_vcr_conftest_common.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high mode and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Diagnostic log emitted multiple times in multi-suite runs
    • Added a module-level _vcr_diagnostic_log_emitted flag to emit_vcr_diagnostic_log so it short-circuits after the first emit, deduplicating output when multiple conftests trigger pytest_terminal_summary in the same session.
Preview (f41a2b1ae3)
diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py
--- a/tests/_vcr_conftest_common.py
+++ b/tests/_vcr_conftest_common.py
@@ -36,6 +36,82 @@
 KEY_FINGERPRINT_MATCHER_NAME = "key_fingerprint"
 KEY_FINGERPRINT_HEADER = "x-litellm-key-fp"
 
+VCR_DIAG_DIR_ENV = "LITELLM_VCR_DIAG_DIR"
+VCR_DIAG_DIR_DEFAULT = "test-results/vcr-diagnostics"
+
+
+def _vcr_diag_dir() -> str:
+    return os.environ.get(VCR_DIAG_DIR_ENV) or VCR_DIAG_DIR_DEFAULT
+
+
+def vcr_diag_write_line(msg: str) -> None:
+    try:
+        directory = _vcr_diag_dir()
+        os.makedirs(directory, exist_ok=True)
+        path = os.path.join(directory, f"{os.getpid()}.log")
+        with open(path, "a", encoding="utf-8") as fh:
+            fh.write(msg.rstrip("\n") + "\n")
+    except OSError:
+        pass
+
+
+def reset_vcr_diag_dir() -> None:
+    if os.environ.get("PYTEST_XDIST_WORKER"):
+        return
+    directory = _vcr_diag_dir()
+    if not os.path.isdir(directory):
+        return
+    try:
+        names = os.listdir(directory)
+    except OSError:
+        return
+    for name in names:
+        if name.endswith(".log"):
+            try:
+                os.remove(os.path.join(directory, name))
+            except OSError:
+                pass
+
+
+_vcr_diagnostic_log_emitted = False
+
+
+def emit_vcr_diagnostic_log(terminalreporter) -> None:
+    global _vcr_diagnostic_log_emitted
+    if _vcr_diagnostic_log_emitted:
+        return
+    directory = _vcr_diag_dir()
+    if not os.path.isdir(directory):
+        return
+    try:
+        files = sorted(f for f in os.listdir(directory) if f.endswith(".log"))
+    except OSError:
+        return
+    if not files:
+        return
+    _vcr_diagnostic_log_emitted = True
+    terminalreporter.write_sep("=", "VCR DIAGNOSTIC LOG", bold=True)
+    terminalreporter.write_line(
+        f"  source dir: {directory}  (also archived as a CI artifact)"
+    )
+    for name in files:
+        path = os.path.join(directory, name)
+        try:
+            with open(path, "r", encoding="utf-8") as fh:
+                content = fh.read()
+        except OSError as exc:
+            terminalreporter.write_line(
+                f"  [failed to read {name}: {type(exc).__name__}: {exc}]"
+            )
+            continue
+        if not content.strip():
+            continue
+        terminalreporter.write_sep("-", name, bold=False)
+        for line in content.splitlines():
+            terminalreporter.write_line(line)
+    terminalreporter.write_sep("=", bold=True)
+
+
 # Intentionally narrower than ``FILTERED_REQUEST_HEADERS``: AWS SigV4 headers
 # carry secrets but their values rotate on every call, so fingerprinting them
 # would defeat caching.
@@ -91,6 +167,24 @@
 VCR_FIXED_MULTIPART_BOUNDARY = "vcr-static-boundary"
 
 
+def pin_httpx_multipart_boundary(monkeypatch) -> None:
+    try:
+        import httpx._multipart as _httpx_multipart
+    except ImportError:
+        return
+
+    _original_init = _httpx_multipart.MultipartStream.__init__
+
+    def _init_with_fixed_boundary(self, data, files, boundary=None, **kwargs):
+        if boundary is None:
+            boundary = VCR_FIXED_MULTIPART_BOUNDARY.encode("ascii")
+        return _original_init(self, data=data, files=files, boundary=boundary, **kwargs)
+
+    monkeypatch.setattr(
+        _httpx_multipart.MultipartStream, "__init__", _init_with_fixed_boundary
+    )
+
+
 def _scrub_response(response):
     if not isinstance(response, dict):
         return response
@@ -139,9 +233,17 @@
     preserves all those checks while shrinking cassettes by ~99%.
     """
     if not isinstance(response, dict):
+        vcr_diag_write_line(
+            f"[vcr-strip-b64] response is {type(response).__name__!r}, not "
+            "dict; skipping b64 scrub"
+        )
         return response
     body = response.get("body")
     if not isinstance(body, dict):
+        vcr_diag_write_line(
+            f"[vcr-strip-b64] response['body'] is {type(body).__name__!r}, "
+            "not dict; skipping b64 scrub"
+        )
         return response
     raw = body.get("string")
     if raw is None:
@@ -151,12 +253,20 @@
         try:
             text = bytes(raw).decode("utf-8")
         except UnicodeDecodeError:
+            vcr_diag_write_line(
+                "[vcr-strip-b64] response body bytes are not valid UTF-8; "
+                "skipping b64 scrub"
+            )
             return response
         was_bytes = True
     elif isinstance(raw, str):
         text = raw
         was_bytes = False
     else:
+        vcr_diag_write_line(
+            f"[vcr-strip-b64] response['body']['string'] is "
+            f"{type(raw).__name__!r}, not bytes/str; skipping b64 scrub"
+        )
         return response
 
     try:
@@ -186,6 +296,35 @@
     return filter_non_2xx_response(_scrub_response(_strip_image_b64_payloads(response)))
 
 
+def _canonical_body(request) -> tuple[bytes, str]:
+    pre_type = type(getattr(request, "body", None)).__name__
+    _materialize_iterable_body(request)
+    body = getattr(request, "body", None)
+    if body is None:
+        return b"", pre_type
+    if isinstance(body, bytes):
+        return body, pre_type
+    if isinstance(body, bytearray):
+        return bytes(body), pre_type
+    if isinstance(body, str):
+        return body.encode("utf-8"), pre_type
+    if isinstance(body, (dict, list)):
+        try:
+            return (
+                json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8"),
+                pre_type,
+            )
+        except (TypeError, ValueError):
+            pass
+    method = getattr(request, "method", "?")
+    uri = getattr(request, "uri", getattr(request, "url", "?"))
+    vcr_diag_write_line(
+        f"[vcr-canonical-body] FALLBACK: {method} {uri} body type "
+        f"{type(body).__name__!r} not coerced to bytes; comparing as b''"
+    )
+    return b"", pre_type
+
+
 def _safe_body_matcher(r1, r2) -> None:
     """Compare request bodies as bytes; never invokes ``json.loads``.
 
@@ -195,27 +334,47 @@
     This matcher is strictly more conservative — the only equivalence
     it gives up vs. the default is "JSON key order doesn't matter".
     """
-    body1 = getattr(r1, "body", None)
-    body2 = getattr(r2, "body", None)
+    body1, pre1 = _canonical_body(r1)
+    body2, pre2 = _canonical_body(r2)
     if body1 == body2:
         return
+    _emit_body_mismatch_diagnostic(r1, r2, body1, body2, pre1, pre2)
+    raise AssertionError("request bodies differ")
 
-    def _to_bytes(b):
-        if b is None:
-            return b""
-        if isinstance(b, bytes):
-            return b
-        if isinstance(b, str):
-            return b.encode("utf-8")
-        return None
 
-    n1 = _to_bytes(body1)
-    n2 = _to_bytes(body2)
-    if n1 is not None and n2 is not None and n1 == n2:
-        return
-    raise AssertionError("request bodies differ")
+def _emit_body_mismatch_diagnostic(r1, r2, body1, body2, pre1, pre2) -> None:
+    def _describe(label, asbytes, pre_type):
+        return (
+            f"  {label}: pre_canonical_type={pre_type!r} length={len(asbytes)} "
+            f"sha256={hashlib.sha256(asbytes).hexdigest()} "
+            f"preview={asbytes[:120]!r}"
+        )
 
+    method_a = getattr(r1, "method", "?")
+    method_b = getattr(r2, "method", "?")
+    url_a = getattr(r1, "uri", getattr(r1, "url", "?"))
+    url_b = getattr(r2, "uri", getattr(r2, "url", "?"))
+    lines = [
+        "[vcr-safe-body-matcher] request body mismatch",
+        f"  request[a]: {method_a} {url_a}",
+        f"  request[b]: {method_b} {url_b}",
+        _describe("body[a]", body1, pre1),
+        _describe("body[b]", body2, pre2),
+    ]
+    if body1 != body2:
+        offset = next(
+            (i for i in range(min(len(body1), len(body2))) if body1[i] != body2[i]),
+            min(len(body1), len(body2)),
+        )
+        start = max(0, offset - 100)
+        end_a = min(len(body1), offset + 100)
+        end_b = min(len(body2), offset + 100)
+        lines.append(f"  first divergent byte offset: {offset}")
+        lines.append(f"  window[a] @ {start}..{end_a}: {body1[start:end_a]!r}")
+        lines.append(f"  window[b] @ {start}..{end_b}: {body2[start:end_b]!r}")
+    vcr_diag_write_line("\n".join(lines))
 
+
 def _iter_header_values(headers, name: str):
     if headers is None:
         return
@@ -271,6 +430,13 @@
             stable = _stable_key_value(header_name, text)
             parts.append(f"{header_name}={stable}")
     if not parts:
+        method = getattr(request, "method", "?")
+        uri = getattr(request, "uri", getattr(request, "url", "?"))
+        vcr_diag_write_line(
+            f"[vcr-key-fingerprint] no API key header found on {method} "
+            f"{uri}; falling back to 'no-key'. If this request should have "
+            "carried auth, something earlier in the pipeline stripped it."
+        )
         return "no-key"
     digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
     return digest[:16]
@@ -360,6 +526,13 @@
     elif isinstance(body, str):
         new_body = body.replace(current_boundary, VCR_FIXED_MULTIPART_BOUNDARY)
     else:
+        vcr_diag_write_line(
+            f"[vcr-multipart-normalize] body normalization SKIPPED: "
+            f"body type {type(body).__name__!r} is not bytes/bytearray/str. "
+            f"content-type={content_type_value!r}. "
+            f"Recorded body will retain the random boundary substring "
+            f"and the safe_body matcher will miss on the next run."
+        )
         return
 
     try:
@@ -389,6 +562,7 @@
     headers = getattr(request, "headers", None)
     if headers is None:
         return request
+    _materialize_iterable_body(request)
     if not any(_iter_header_values(headers, KEY_FINGERPRINT_HEADER)):
         fingerprint = _compute_key_fingerprint(request)
         try:
@@ -400,6 +574,56 @@
     return request
 
 
+def _materialize_iterable_body(request) -> None:
+    body = getattr(request, "body", None)
+    if body is None or isinstance(body, (bytes, bytearray, str)):
+        return
+    if not hasattr(body, "__next__"):
+        return
+    try:
+        chunks = list(body)
+    except TypeError:
+        return
+
+    out = _coalesce_chunks_to_bytes(chunks)
+    if out is None:
+        method = getattr(request, "method", "?")
+        uri = getattr(request, "uri", getattr(request, "url", "?"))
+        first_type = type(chunks[0]).__name__ if chunks else "empty"
+        vcr_diag_write_line(
+            f"[vcr-materialize] FALLBACK: {method} {uri} chunk type "
+            f"{first_type!r} not coerced to bytes; storing b''"
+        )
+        out = b""
+
+    try:
+        request.body = out
+    except (AttributeError, TypeError):
+        pass
+
+    for attr in ("_was_iter", "_was_file"):
+        try:
+            setattr(request, attr, False)
+        except (AttributeError, TypeError):
+            pass
+
+
+def _coalesce_chunks_to_bytes(chunks):
+    if not chunks:
+        return b""
+    first = chunks[0]
+    try:
+        if isinstance(first, int):
+            return bytes(chunks)
+        if isinstance(first, (bytes, bytearray)):
+            return b"".join(c if isinstance(c, bytes) else bytes(c) for c in chunks)
+        if isinstance(first, str):
+            return "".join(chunks).encode("utf-8")
+    except (TypeError, ValueError):
+        return None
+    return None
+
+
 def _key_fingerprint_matcher(r1, r2) -> None:
     def _fp(req):
         for value in _iter_header_values(
@@ -410,7 +634,17 @@
             return value if isinstance(value, str) else str(value)
         return "no-key"
 
-    if _fp(r1) != _fp(r2):
+    fp1, fp2 = _fp(r1), _fp(r2)
+    if fp1 != fp2:
+        method_a = getattr(r1, "method", "?")
+        method_b = getattr(r2, "method", "?")
+        url_a = getattr(r1, "uri", getattr(r1, "url", "?"))
+        url_b = getattr(r2, "uri", getattr(r2, "url", "?"))
+        vcr_diag_write_line(
+            "[vcr-key-fingerprint-matcher] API key fingerprints differ\n"
+            f"  request[a]: {method_a} {url_a} fingerprint={fp1!r}\n"
+            f"  request[b]: {method_b} {url_b} fingerprint={fp2!r}"
+        )
         raise AssertionError("API key fingerprints differ")
 
 

diff --git a/tests/_vcr_redis_persister.py b/tests/_vcr_redis_persister.py
--- a/tests/_vcr_redis_persister.py
+++ b/tests/_vcr_redis_persister.py
@@ -168,6 +168,7 @@
             key = redis_key_for(cassette_path)
             passed = _passed_by_cassette_key.pop(key, True)
             episode_count = len(cassette_dict.get("requests", []) or [])
+            _log_episode_body_hashes(key, cassette_dict)
             if episode_count > MAX_EPISODES_PER_CASSETTE:
                 _log.warning(
                     "VCR redis save refused for %s; cassette has %d episodes "
@@ -210,6 +211,35 @@
     return _RedisPersister
 
 
+def _log_episode_body_hashes(key: str, cassette_dict) -> None:
+    import hashlib
+
+    from tests._vcr_conftest_common import vcr_diag_write_line
+
+    requests = cassette_dict.get("requests", []) or []
+    for i, req in enumerate(requests):
+        body = getattr(req, "body", None)
+        if body is None:
+            body_bytes = b""
+        elif isinstance(body, (bytes, bytearray)):
+            body_bytes = bytes(body)
+        elif isinstance(body, str):
+            body_bytes = body.encode("utf-8")
+        else:
+            vcr_diag_write_line(
+                f"[vcr-episode-body-hash] {key} episode[{i}]: body type="
+                f"{type(body).__name__!r} not bytes/bytearray/str -- cannot hash"
+            )
+            continue
+        method = getattr(req, "method", "?")
+        uri = getattr(req, "uri", getattr(req, "url", "?"))
+        vcr_diag_write_line(
+            f"[vcr-episode-body-hash] {key} episode[{i}] {method} {uri} "
+            f"body sha256={hashlib.sha256(body_bytes).hexdigest()} "
+            f"len={len(body_bytes)} preview={body_bytes[:120]!r}"
+        )
+
+
 def filter_non_2xx_response(response):
     if not isinstance(response, dict):
         return response

diff --git a/tests/audio_tests/conftest.py b/tests/audio_tests/conftest.py
--- a/tests/audio_tests/conftest.py
+++ b/tests/audio_tests/conftest.py
@@ -10,9 +10,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -44,6 +46,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -57,3 +60,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/guardrails_tests/conftest.py b/tests/guardrails_tests/conftest.py
--- a/tests/guardrails_tests/conftest.py
+++ b/tests/guardrails_tests/conftest.py
@@ -21,9 +21,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -55,6 +57,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -160,3 +163,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/image_gen_tests/conftest.py b/tests/image_gen_tests/conftest.py
--- a/tests/image_gen_tests/conftest.py
+++ b/tests/image_gen_tests/conftest.py
@@ -14,9 +14,12 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
+    pin_httpx_multipart_boundary,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -33,6 +36,14 @@
     loop.close()
 
 
+@pytest.fixture(scope="session", autouse=True)
+def _pin_multipart_boundary():
+    monkeypatch = pytest.MonkeyPatch()
+    pin_httpx_multipart_boundary(monkeypatch)
+    yield
+    monkeypatch.undo()
+
+
 @pytest.fixture(scope="module")
 def vcr_config():
     return vcr_config_dict()
@@ -58,6 +69,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -71,3 +83,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py
--- a/tests/image_gen_tests/test_image_edits.py
+++ b/tests/image_gen_tests/test_image_edits.py
@@ -103,12 +103,6 @@
 pwd = os.path.dirname(os.path.realpath(__file__))
 
 
-# Image fixtures must be regenerated per access — module-level
-# ``open(...)`` handles get consumed after a single multipart upload, leaving
-# subsequent tests in the same process to send empty bodies. That non-determinism
-# (a) blows the recorded cassette past ``MAX_EPISODES_PER_CASSETTE`` so the
-# persister refuses to save (see ``tests/_vcr_redis_persister.py``), and
-# (b) re-bills the live image edit endpoint on every CI run.
 def _read_image_bytes(filename: str) -> bytes:
     with open(os.path.join(pwd, filename), "rb") as f:
         return f.read()
@@ -119,32 +113,20 @@
 
 
 def _make_test_images() -> list:
-    """Return a fresh pair of image streams seeded with the fixture bytes.
+    return [_ISHAAN_GITHUB_BYTES, _LITELLM_SITE_BYTES]
 
-    Use this everywhere you'd previously have used the module-level
-    ``TEST_IMAGES``. Each call returns brand new ``BytesIO`` objects whose
-    file pointers start at 0, so multipart uploads encode the full image
-    bytes on every test invocation. Parametrized and ``flaky``-retried
-    test methods call ``get_base_image_edit_call_args`` once per
-    invocation, so a fresh stream per call is sufficient — the factory
-    must not auto-rewind on EOF or the SDK's multipart writer will read
-    the same bytes forever (worker OOM).
-    """
+
+def _make_single_test_image() -> bytes:
+    return _ISHAAN_GITHUB_BYTES
+
+
+def get_test_images_as_bytesio():
     return [
         BytesIO(_ISHAAN_GITHUB_BYTES),
         BytesIO(_LITELLM_SITE_BYTES),
     ]
 
 
-def _make_single_test_image() -> BytesIO:
-    return BytesIO(_ISHAAN_GITHUB_BYTES)
-
-
-def get_test_images_as_bytesio():
-    """Helper function to get test images as BytesIO objects"""
-    return _make_test_images()
-
-
 class TestOpenAIImageEditGPTImage1(BaseLLMImageEditTest):
     """
     Concrete implementation of BaseLLMImageEditTest for OpenAI image edits.
@@ -710,10 +692,9 @@
     try:
         prompt = "Create a cohesive artistic style across all images"
 
-        # Test with mixed BytesIO and file objects
         mixed_images = [
-            _make_single_test_image(),  # File object
-            get_test_images_as_bytesio()[1],  # BytesIO object
+            _make_single_test_image(),
+            get_test_images_as_bytesio()[1],
         ]
 
         result = await aimage_edit(

diff --git a/tests/litellm_utils_tests/conftest.py b/tests/litellm_utils_tests/conftest.py
--- a/tests/litellm_utils_tests/conftest.py
+++ b/tests/litellm_utils_tests/conftest.py
@@ -17,9 +17,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -86,6 +88,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -116,3 +119,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/llm_responses_api_testing/conftest.py b/tests/llm_responses_api_testing/conftest.py
--- a/tests/llm_responses_api_testing/conftest.py
+++ b/tests/llm_responses_api_testing/conftest.py
@@ -18,9 +18,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -52,6 +54,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -116,3 +119,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/llm_translation/conftest.py b/tests/llm_translation/conftest.py
--- a/tests/llm_translation/conftest.py
+++ b/tests/llm_translation/conftest.py
@@ -23,9 +23,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -73,6 +75,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -82,6 +85,7 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)
 
 
 # ---------------------------------------------------------------------------

diff --git a/tests/local_testing/conftest.py b/tests/local_testing/conftest.py
--- a/tests/local_testing/conftest.py
+++ b/tests/local_testing/conftest.py
@@ -27,9 +27,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -84,6 +86,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -93,6 +96,7 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)
 
 
 # ---------------------------------------------------------------------------

diff --git a/tests/logging_callback_tests/conftest.py b/tests/logging_callback_tests/conftest.py
--- a/tests/logging_callback_tests/conftest.py
+++ b/tests/logging_callback_tests/conftest.py
@@ -24,9 +24,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -79,6 +81,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -229,3 +232,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/ocr_tests/conftest.py b/tests/ocr_tests/conftest.py
--- a/tests/ocr_tests/conftest.py
+++ b/tests/ocr_tests/conftest.py
@@ -17,9 +17,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -51,6 +53,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -64,3 +67,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/pass_through_unit_tests/conftest.py b/tests/pass_through_unit_tests/conftest.py
--- a/tests/pass_through_unit_tests/conftest.py
+++ b/tests/pass_through_unit_tests/conftest.py
@@ -10,9 +10,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
     install_live_call_probe,
     record_vcr_outcome,
     register_persister_if_enabled,
+    reset_vcr_diag_dir,
     vcr_config_dict,
 )
 
@@ -56,6 +58,7 @@
 
 def pytest_configure(config):
     _verbose_state.remember_pluginmanager(config)
+    reset_vcr_diag_dir()
 
 
 def pytest_runtest_logreport(report):
@@ -71,3 +74,4 @@
 def pytest_terminal_summary(terminalreporter, exitstatus, config):
     emit_cassette_cache_session_banner(terminalreporter)
     emit_vcr_classification_summary(terminalreporter)
+    emit_vcr_diagnostic_log(terminalreporter)

diff --git a/tests/router_unit_tests/conftest.py b/tests/router_unit_tests/conftest.py
--- a/tests/router_unit_tests/conftest.py
+++ b/tests/router_unit_tests/conftest.py
@@ -17,9 +17,11 @@
     apply_vcr_auto_marker_to_items,
     emit_cassette_cache_session_banner,
     emit_vcr_classification_summary,
+    emit_vcr_diagnostic_log,
... diff truncated: showing 800 of 878 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 6e0b7c1a94a72aa291b7d2a04e4ad2d7b4f23f7f. Configure here.

Comment thread tests/_vcr_conftest_common.py
@mateo-berri
mateo-berri force-pushed the litellm_stabilize_image_edit_vcr_cassettes branch from 6e0b7c1 to bfa1cb8 Compare May 17, 2026 16:51
…mission

Co-authored-by: Yassin Kortam <yassin@berri.ai>
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

…ures

Diagnostic shows audio_testing was silently re-recording 50+ live Whisper
episodes per CI run (over MAX_EPISODES_PER_CASSETTE, so the persister
refused to save). Two changes:

* Move the session-autouse _pin_multipart_boundary fixture into the
  shared _vcr_conftest_common module so every VCR-using suite picks it
  up via a single import. image_gen had it inline; the other 12 suites
  silently lacked it.
* Replace the module-level open("rb") audio file handles in test_whisper
  with cached bytes + a per-call (filename, bytes, mimetype) tuple,
  mirroring the image_edits raw-bytes pattern. Stops the file-pointer-
  at-EOF bug where the second test got an empty multipart body.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yuneng-berri
yuneng-berri merged commit bb448b0 into litellm_internal_staging May 18, 2026
116 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_stabilize_image_edit_vcr_cassettes branch May 18, 2026 16:15
blake-hamm added a commit to blake-hamm/bhamm-lab that referenced this pull request Jun 16, 2026
…to v1.89.0 (#200)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [https://github.com/BerriAI/litellm.git](https://github.com/BerriAI/litellm) | minor | `v1.85.1` → `v1.89.0` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/155) for more information.

---

### Release Notes

<details>
<summary>BerriAI/litellm (https://github.com/BerriAI/litellm.git)</summary>

### [`v1.89.0`](https://github.com/BerriAI/litellm/releases/tag/v1.89.0)

[Compare Source](https://github.com/BerriAI/litellm/compare/v1.88.2...v1.89.0)

#### Verify Docker Image Signature

All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](https://github.com/BerriAI/litellm/commit/0112e53046018d726492c814b3644b7d376029d0).

**Verify using the pinned commit hash (recommended):**

A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
  ghcr.io/berriai/litellm:v1.89.0
```

**Verify using the release tag (convenience):**

Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/v1.89.0/cosign.pub \
  ghcr.io/berriai/litellm:v1.89.0
```

Expected output:

```
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
```

***

#### What's Changed

- test(responses): bump deprecated gemini-3-pro-preview to gemini-3.1-pro-preview by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29433](https://github.com/BerriAI/litellm/pull/29433)
- fix: map mistral/ministral-8b-latest in model price map by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29453](https://github.com/BerriAI/litellm/pull/29453)
- fix(datadog): split oversized batches on 413 instead of re-queueing forever by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29444](https://github.com/BerriAI/litellm/pull/29444)
- feat(otel): allowlist team\_metadata sub-keys promoted to baggage by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29442](https://github.com/BerriAI/litellm/pull/29442)
- fix: stop use\_chat\_completions\_api flag from leaking into provider request body by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29447](https://github.com/BerriAI/litellm/pull/29447)
- fix(anthropic, fireworks): inline legacy $ref defs in tool schemas by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;28646](https://github.com/BerriAI/litellm/pull/28646)
- fix(proxy): omit OpenAI \[DONE] on google-genai streamGenerateContent by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29426](https://github.com/BerriAI/litellm/pull/29426)
- ci(release): create stable/X.Y.x line branch on X.Y.0 tags by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29457](https://github.com/BerriAI/litellm/pull/29457)
- fix(vector-stores): support engines URL for Vertex AI Search by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;27885](https://github.com/BerriAI/litellm/pull/27885)
- fix(ui): render caller-supplied filter options in caller order by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29462](https://github.com/BerriAI/litellm/pull/29462)
- fix(batches): skip unnecessary batch input file reads by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29114](https://github.com/BerriAI/litellm/pull/29114)
- docs(agents): clarify when to create new test files by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29472](https://github.com/BerriAI/litellm/pull/29472)
- Litellm OSS Staging by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29161](https://github.com/BerriAI/litellm/pull/29161)
- fix(mcp): clear allowed\_tools and tool overrides on MCP server edit by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29411](https://github.com/BerriAI/litellm/pull/29411)
- Litellm OSS Staging 010626 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29422](https://github.com/BerriAI/litellm/pull/29422)
- fix(ci): make CircleCI rerun-failed-tests collect tests when 2+ test files fail by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29475](https://github.com/BerriAI/litellm/pull/29475)
- feat(a2a): watsonx Orchestrate agent provider by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29410](https://github.com/BerriAI/litellm/pull/29410)
- fix(azure\_ai): strip tool-level extra fields on 400 and retry by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29479](https://github.com/BerriAI/litellm/pull/29479)
- fix(docs): remove fixed dimensions from README hero image by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29496](https://github.com/BerriAI/litellm/pull/29496)
- Litellm oss staging by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29492](https://github.com/BerriAI/litellm/pull/29492)
- fix: small CLAUDE.md nits by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29504](https://github.com/BerriAI/litellm/pull/29504)
- Add MCP semantic conventions to otelv2 by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29468](https://github.com/BerriAI/litellm/pull/29468)
- fix(passthrough): emit otel guardrail span when a guardrail blocks by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29470](https://github.com/BerriAI/litellm/pull/29470)
- fix(proxy): strip NUL bytes from spend log payloads to prevent PostgreSQL 22P05 by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;29515](https://github.com/BerriAI/litellm/pull/29515)
- \[internal copy of [#&#8203;28008](https://github.com/BerriAI/litellm/issues/28008)] Support MCP OAuth passthrough and issuer-scoped JWT auth by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28356](https://github.com/BerriAI/litellm/pull/28356)
- feat(vector-stores): forward per-request params to Vertex AI Search by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29459](https://github.com/BerriAI/litellm/pull/29459)
- feat(proxy): add per-MCP-server RPM rate limiting for keys and teams by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29482](https://github.com/BerriAI/litellm/pull/29482)
- fix(tests): drop module-level test calls that break local\_testing collection by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29520](https://github.com/BerriAI/litellm/pull/29520)
- feat(agents): add LangFlow agent provider with A2A session bridging by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28963](https://github.com/BerriAI/litellm/pull/28963)
- fix(ui/agents): make A2A skill tags enterable and validated by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29512](https://github.com/BerriAI/litellm/pull/29512)
- \[internal copy of [#&#8203;29232](https://github.com/BerriAI/litellm/issues/29232)] feat: route future Claude models to Anthropic provider via pattern matching by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29239](https://github.com/BerriAI/litellm/pull/29239)
- fix(tests): drop import-time completion call in test\_register\_model by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29521](https://github.com/BerriAI/litellm/pull/29521)
- test: stabilize batch VCR coverage and stop live upload/network leaks by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29477](https://github.com/BerriAI/litellm/pull/29477)
- \[internal copy of [#&#8203;29003](https://github.com/BerriAI/litellm/issues/29003)] fix(vertex\_ai): use user-supplied api\_base as is for Model Garden OpenAI-compat path by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29530](https://github.com/BerriAI/litellm/pull/29530)
- feat(proxy): native /health/drain preStop hook for graceful shutdown by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29439](https://github.com/BerriAI/litellm/pull/29439)
- fix(auth): preserve 401 status for expired JWTs in OTel traces by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29510](https://github.com/BerriAI/litellm/pull/29510)
- fix(otel): capture 401 error details in management endpoint spans by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29535](https://github.com/BerriAI/litellm/pull/29535)
- test(proxy/utils): pin bottom-of-file helper behavior by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29509](https://github.com/BerriAI/litellm/pull/29509)
- test(proxy/utils): pin PrismaClient and spend-update behavior by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29488](https://github.com/BerriAI/litellm/pull/29488)
- test(proxy/utils): pin ProxyLogging behavior by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29485](https://github.com/BerriAI/litellm/pull/29485)
- fix: missing span for guardrail passthrough by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29552](https://github.com/BerriAI/litellm/pull/29552)
- fix(auth): let internal users view search tools by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29542](https://github.com/BerriAI/litellm/pull/29542)
- fix: missing mcp otel attributes by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29554](https://github.com/BerriAI/litellm/pull/29554)
- fix(proxy): resolve managed video model ids for auth by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29545](https://github.com/BerriAI/litellm/pull/29545)
- fix(key\_generate): allow team members to create keys on org-scoped teams by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;29310](https://github.com/BerriAI/litellm/pull/29310)
- test(pass-through): move Gemini pass-through tests to gemini-3.1-flash-lite by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29595](https://github.com/BerriAI/litellm/pull/29595)
- Litellm oss staging 030626 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29578](https://github.com/BerriAI/litellm/pull/29578)
- Fix : a2a bugs 030626 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29566](https://github.com/BerriAI/litellm/pull/29566)
- \[internal copy of [#&#8203;29533](https://github.com/BerriAI/litellm/issues/29533)] fix(anthropic/adapter): emit thinking block for reasoning\_content-only streaming chunks by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29600](https://github.com/BerriAI/litellm/pull/29600)
- ci: reproduce default-Windows wheel install to guard MAX\_PATH by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29597](https://github.com/BerriAI/litellm/pull/29597)
- fix(vertex): strip output\_config.effort for Vertex Claude models that reject it (Haiku 4.5) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29585](https://github.com/BerriAI/litellm/pull/29585)
- Litellm websocket improvements by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29563](https://github.com/BerriAI/litellm/pull/29563)
- feat(arize/phoenix): OpenInference rendering parity — tool\_calls, cost, passthrough I/O, session/user, multimodal, cache tokens by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;28800](https://github.com/BerriAI/litellm/pull/28800)
- \[internal copy of [#&#8203;29550](https://github.com/BerriAI/litellm/issues/29550)] fix: passthrough endpoints duplicate logs by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29598](https://github.com/BerriAI/litellm/pull/29598)
- fix(ci): keep coverage rename green when a parallel node runs no tests by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29608](https://github.com/BerriAI/litellm/pull/29608)
- test(vcr): close out the remaining VCR live-call leaks by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29603](https://github.com/BerriAI/litellm/pull/29603)
- fix(key\_generate): exempt UI/CLI session tokens from the budget ceiling for team keys by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29612](https://github.com/BerriAI/litellm/pull/29612)
- fix(realtime): allow null transcripts in stream logging payloads by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;29625](https://github.com/BerriAI/litellm/pull/29625)
- build(ui): migrate eslint to flat config + bump eslint-config-next to 16 by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29626](https://github.com/BerriAI/litellm/pull/29626)
- fix(key\_generate): scope session-token team-key budget exemption to caller-supplied team\_id by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29641](https://github.com/BerriAI/litellm/pull/29641)
- fix(proxy): disable proxy buffering on streaming SSE responses by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29557](https://github.com/BerriAI/litellm/pull/29557)
- fix(mcp): gate /public/mcp\_hub strictly on litellm.public\_mcp\_servers by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;27764](https://github.com/BerriAI/litellm/pull/27764)
- ci(ui): frontend-lint job enforcing prettier + eslint on changed files by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29633](https://github.com/BerriAI/litellm/pull/29633)
- fix(gemini): googleSearch + server-side tools and googleMaps JSON schema by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29582](https://github.com/BerriAI/litellm/pull/29582)
- fix(proxy): passthrough 404 when SERVER\_ROOT\_PATH is set by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29658](https://github.com/BerriAI/litellm/pull/29658)
- fix(gemini-realtime): use GA event names for Pipecat 1.3.x compatibility by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29662](https://github.com/BerriAI/litellm/pull/29662)
- Litellm oss staging 040626 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29671](https://github.com/BerriAI/litellm/pull/29671)
- style(ui): prettier formatting pass over the dashboard by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29622](https://github.com/BerriAI/litellm/pull/29622)
- chore: ignore prettier dashboard reformat in git blame by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29695](https://github.com/BerriAI/litellm/pull/29695)
- fix(helm): Enable Backend Deployment to mount Gateway config.yaml by [@&#8203;tin-berri](https://github.com/tin-berri) in [#&#8203;29605](https://github.com/BerriAI/litellm/pull/29605)
- \[internal copy of [#&#8203;29277](https://github.com/BerriAI/litellm/issues/29277)] fix(proxy): add default=None to LiteLLM\_TeamMembership.litellm\_budget\_table by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29684](https://github.com/BerriAI/litellm/pull/29684)
- test: make custom\_tokenizer proxy tests hermetic by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29643](https://github.com/BerriAI/litellm/pull/29643)
- test(proxy): stop running real-DB tests in GitHub Actions unit jobs by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29700](https://github.com/BerriAI/litellm/pull/29700)
- chore(ui): remove the bare-fetch lint rule by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29712](https://github.com/BerriAI/litellm/pull/29712)
- Litellm jwt mapping virtualkeys by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;28510](https://github.com/BerriAI/litellm/pull/28510)
- refactor(ui): shared HTTP client + location-pinned fetch() lint rule by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29723](https://github.com/BerriAI/litellm/pull/29723)
- fix(proxy): stop team BYOK model name corruption on model edit by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29731](https://github.com/BerriAI/litellm/pull/29731)
- \[internal copy of [#&#8203;29511](https://github.com/BerriAI/litellm/issues/29511)] feat(guardrails): add sensitive data routing to on-premise models by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29531](https://github.com/BerriAI/litellm/pull/29531)
- fix(proxy/hooks): populate llm\_provider on internal rate-limit errors by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;27707](https://github.com/BerriAI/litellm/pull/27707)
- fix(vertex/anthropic): handle namespace tools and strip client\_metadata for codex compatibility by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29489](https://github.com/BerriAI/litellm/pull/29489)
- Support OAuth M2M for Databricks Apps A2A agents by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29586](https://github.com/BerriAI/litellm/pull/29586)
- fix: small CLAUDE.md nit by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29749](https://github.com/BerriAI/litellm/pull/29749)
- fix(anthropic): route Claude Opus 4.8 through adaptive thinking by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29702](https://github.com/BerriAI/litellm/pull/29702)
- fix(proxy): persist oauth2\_flow on MCP server registration by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;29690](https://github.com/BerriAI/litellm/pull/29690)
- \[internal copy of [#&#8203;27491](https://github.com/BerriAI/litellm/issues/27491)] fix(realtime): Fix Realtime Audio Token Cost Tracking by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29722](https://github.com/BerriAI/litellm/pull/29722)
- fix(galileo): use ingest traces API and standard logging payload by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29651](https://github.com/BerriAI/litellm/pull/29651)
- fix(auth): expand all-team-models sentinel in can\_key\_call\_model for batch validation by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29746](https://github.com/BerriAI/litellm/pull/29746)
- test(vcr): stop refreshing cassette TTL on read so cassettes lapse after 24h by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29784](https://github.com/BerriAI/litellm/pull/29784)
- test(ci): record/replay OpenAI image gen so the spend E2E isn't outage-bound by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29787](https://github.com/BerriAI/litellm/pull/29787)
- fix(ui): route MCP playground auth by oauth2 mode instead of token\_url by [@&#8203;tin-berri](https://github.com/tin-berri) in [#&#8203;29714](https://github.com/BerriAI/litellm/pull/29714)
- refactor(ui): centralize proxy base URL resolution into tested resolver by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29793](https://github.com/BerriAI/litellm/pull/29793)
- Litellm oss staging 050626 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29774](https://github.com/BerriAI/litellm/pull/29774)
- test(google): add google-genai SDK proxy integration tests by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29781](https://github.com/BerriAI/litellm/pull/29781)
- fix(jwt): use resolved DB user\_id for spend on legacy email match by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;29217](https://github.com/BerriAI/litellm/pull/29217)
- feat(ui): generate dashboard API types from the proxy OpenAPI spec by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29816](https://github.com/BerriAI/litellm/pull/29816)
- fix(proxy): drop deleted team BYOK model name from team.models by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29820](https://github.com/BerriAI/litellm/pull/29820)
- feat(mcp): per-server env vars with global + per-user scopes by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28917](https://github.com/BerriAI/litellm/pull/28917)
- refactor(ui): route behavior-preserving networking calls through apiClient by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29806](https://github.com/BerriAI/litellm/pull/29806)
- fix(mcp): persist Tools-tab MCP OAuth token to DB by [@&#8203;tin-berri](https://github.com/tin-berri) in [#&#8203;29809](https://github.com/BerriAI/litellm/pull/29809)
- fix(ui): require new expiration when regenerating an expired key by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;29838](https://github.com/BerriAI/litellm/pull/29838)
- refactor(ui): route query-building networking calls through apiClient by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29815](https://github.com/BerriAI/litellm/pull/29815)
- Make the image-gen record/replay proxy report cache mode and per-request HIT/MISS by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29802](https://github.com/BerriAI/litellm/pull/29802)
- feat(proxy): hot-reload .env in dev when running with --reload by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29783](https://github.com/BerriAI/litellm/pull/29783)
- fix(ui): stop MCP playground tool calls from sending twice by [@&#8203;tin-berri](https://github.com/tin-berri) in [#&#8203;29821](https://github.com/BerriAI/litellm/pull/29821)
- feat(fal\_ai): add Nano Banana / Gemini 2.5 Flash Image generation support by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29798](https://github.com/BerriAI/litellm/pull/29798)
- Title: Fix managed batch cancel credential resolution by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29734](https://github.com/BerriAI/litellm/pull/29734)
- Title: fix(proxy): resolve vector store file list credentials from team deployments by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29739](https://github.com/BerriAI/litellm/pull/29739)
- refactor: convert AWS and GCP Terraform stacks into reusable modules … by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28103](https://github.com/BerriAI/litellm/pull/28103)
- chore(ui): build ui for release by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29853](https://github.com/BerriAI/litellm/pull/29853)
- fix(terraform/gcp): prompt for image\_registry in DeployStack one-click by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29852](https://github.com/BerriAI/litellm/pull/29852)
- fix(terraform/gcp): abandon SQL user on destroy by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29855](https://github.com/BerriAI/litellm/pull/29855)
- Extend the record/replay proxy to chat, embeddings, moderations, rerank, and Anthropic by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29847](https://github.com/BerriAI/litellm/pull/29847)
- chore(deps): bump deps by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29860](https://github.com/BerriAI/litellm/pull/29860)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29861](https://github.com/BerriAI/litellm/pull/29861)
- fix: 400 on Anthropic context overflow; seed identity on failed auth by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29848](https://github.com/BerriAI/litellm/pull/29848)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29862](https://github.com/BerriAI/litellm/pull/29862)
- chore(release): patch v1.89.0-rc.1 with [#&#8203;30064](https://github.com/BerriAI/litellm/issues/30064) (Claude Fable 5) for v1.89.0-rc.2 by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;30143](https://github.com/BerriAI/litellm/pull/30143)

**Full Changelog**: <https://github.com/BerriAI/litellm/compare/v1.88.0...v1.89.0>

### [`v1.88.2`](https://github.com/BerriAI/litellm/releases/tag/v1.88.2)

[Compare Source](https://github.com/BerriAI/litellm/compare/v1.88.1...v1.88.2)

#### Verify Docker Image Signature

All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](https://github.com/BerriAI/litellm/commit/0112e53046018d726492c814b3644b7d376029d0).

**Verify using the pinned commit hash (recommended):**

A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
  ghcr.io/berriai/litellm:v1.88.2
```

**Verify using the release tag (convenience):**

Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/v1.88.2/cosign.pub \
  ghcr.io/berriai/litellm:v1.88.2
```

Expected output:

```
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
```

***

#### What's Changed

- chore(release): backport Fable 5, batch-file auth, CrowdStrike AIDR, Mantle Responses SigV4, and NetApp streaming-cost fix to stable/1.88.x and cut 1.88.2 by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;30144](https://github.com/BerriAI/litellm/pull/30144)
- chore(release): backport DB-resilience, passthrough, model-info, budget, and deps fixes to stable/1.88.x by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;30408](https://github.com/BerriAI/litellm/pull/30408)

**Full Changelog**: <https://github.com/BerriAI/litellm/compare/v1.88.1...v1.88.2>

### [`v1.88.1`](https://github.com/BerriAI/litellm/releases/tag/v1.88.1)

[Compare Source](https://github.com/BerriAI/litellm/compare/v1.88.0...v1.88.1)

#### Verify Docker Image Signature

All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](https://github.com/BerriAI/litellm/commit/0112e53046018d726492c814b3644b7d376029d0).

**Verify using the pinned commit hash (recommended):**

A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
  ghcr.io/berriai/litellm:v1.88.1
```

**Verify using the release tag (convenience):**

Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/v1.88.1/cosign.pub \
  ghcr.io/berriai/litellm:v1.88.1
```

Expected output:

```
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
```

***

#### What's Changed

- build(deps): bump pyjwt to 2.13.0 and ws override to 8.20.1 (1.88.x) by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29987](https://github.com/BerriAI/litellm/pull/29987)
- chore(release): bump version to 1.88.1 by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29989](https://github.com/BerriAI/litellm/pull/29989)

**Full Changelog**: <https://github.com/BerriAI/litellm/compare/v1.88.0...v1.88.1>

### [`v1.88.0`](https://github.com/BerriAI/litellm/releases/tag/v1.88.0)

[Compare Source](https://github.com/BerriAI/litellm/compare/v1.87.3...v1.88.0)

#### Verify Docker Image Signature

All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](https://github.com/BerriAI/litellm/commit/0112e53046018d726492c814b3644b7d376029d0).

**Verify using the pinned commit hash (recommended):**

A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \
  ghcr.io/berriai/litellm:v1.88.0
```

**Verify using the release tag (convenience):**

Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules:

```bash
cosign verify \
  --key https://raw.githubusercontent.com/BerriAI/litellm/v1.88.0/cosign.pub \
  ghcr.io/berriai/litellm:v1.88.0
```

Expected output:

```
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key
```

***

#### What's Changed

- fix(proxy): gate team allowed\_passthrough\_routes to proxy admins by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28097](https://github.com/BerriAI/litellm/pull/28097)
- fix(tests): stabilize image-edit VCR cassettes to stop live gpt-image-1 spend by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28110](https://github.com/BerriAI/litellm/pull/28110)
- fix(bedrock/cohere): send embedding\_types as JSON array, not string by [@&#8203;ishaan-berri](https://github.com/ishaan-berri) in [#&#8203;28172](https://github.com/BerriAI/litellm/pull/28172)
- fix(tests): migrate realtime + rerank tests off shut-down upstream models by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28191](https://github.com/BerriAI/litellm/pull/28191)
- fix(caching): replay openai/responses bridge cache hits as chat streams by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28158](https://github.com/BerriAI/litellm/pull/28158)
- Litellm oss staging by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28161](https://github.com/BerriAI/litellm/pull/28161)
- feat(prometheus): add user\_email and user\_alias to user budget metrics by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28155](https://github.com/BerriAI/litellm/pull/28155)
- test(callbacks): harden flaky proxy callback-leak detector by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28195](https://github.com/BerriAI/litellm/pull/28195)
- fix(bedrock): sanitize batch metadata to prevent Pydantic ValidationError by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28202](https://github.com/BerriAI/litellm/pull/28202)
- fix(deepseek): use native /anthropic/v1/messages endpoint and sanitize tools by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28200](https://github.com/BerriAI/litellm/pull/28200)
- feat(ui): add Interactions API endpoint to playground with SSE streaming by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28156](https://github.com/BerriAI/litellm/pull/28156)
- fix(proxy): decode bytes and pass-through SSE for Google-native streamGenerateContent ([#&#8203;27444](https://github.com/BerriAI/litellm/issues/27444)) by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28213](https://github.com/BerriAI/litellm/pull/28213)
- refactor(bedrock/sagemaker): switch to lazy loading for response stre… by [@&#8203;harish-berri](https://github.com/harish-berri) in [#&#8203;28189](https://github.com/BerriAI/litellm/pull/28189)
- \[Refactor] UI - Spend Logs: consolidate filter state and extract components by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;25847](https://github.com/BerriAI/litellm/pull/25847)
- fix(tests): replace shut-down gpt-4o-audio-preview with gpt-audio-1.5 by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28281](https://github.com/BerriAI/litellm/pull/28281)
- chore(ci): bump versions by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28287](https://github.com/BerriAI/litellm/pull/28287)
- feat: propagate team\_id and team\_alias to all child OTEL spans by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28273](https://github.com/BerriAI/litellm/pull/28273)
- Day 0 support : Gemini 3.5 Flash by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28268](https://github.com/BerriAI/litellm/pull/28268)
- Gemini managed agents support by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28270](https://github.com/BerriAI/litellm/pull/28270)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28292](https://github.com/BerriAI/litellm/pull/28292)
- feat(gemini): add gemini-3.1-flash-lite model cost map by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28320](https://github.com/BerriAI/litellm/pull/28320)
- fix(spend\_counter): seed Redis counter via SET NX to prevent cross-pod double-seed by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;27854](https://github.com/BerriAI/litellm/pull/27854)
- fix(proxy): normalize batch file IDs before ManagedObjectTable write by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28339](https://github.com/BerriAI/litellm/pull/28339)
- fix(router): use forwarded model\_id for native Azure container IDs by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;27921](https://github.com/BerriAI/litellm/pull/27921)
- fix(ui): restore log filter loading indicator by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28282](https://github.com/BerriAI/litellm/pull/28282)
- test(e2e): migrate runner to uv, add All Proxy Models key test by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28313](https://github.com/BerriAI/litellm/pull/28313)
- feat(ui): team passthrough routes create parity + edit load fix by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28098](https://github.com/BerriAI/litellm/pull/28098)
- fix(mcp): JWT on tools/list and REST tools/call server resolution by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28227](https://github.com/BerriAI/litellm/pull/28227)
- feat(interactions): migrate to Google Interactions API steps schema (May 2026) by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28153](https://github.com/BerriAI/litellm/pull/28153)
- test(ui-e2e): admin key creation with a specific proxy model by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28365](https://github.com/BerriAI/litellm/pull/28365)
- fix(vertex\_ai): omit function\_call id on Vertex Gemini 3.5+ tool turns by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28324](https://github.com/BerriAI/litellm/pull/28324)
- feat(mcp): allow native MCP OAuth support for cursor by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28327](https://github.com/BerriAI/litellm/pull/28327)
- fix(interactions): never drop streamed text deltas; always emit terminal completion by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28394](https://github.com/BerriAI/litellm/pull/28394)
- fix(proxy): expose Prisma idle/connect timeout + extra DB URL params by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28395](https://github.com/BerriAI/litellm/pull/28395)
- Litellm oss staging 1 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28337](https://github.com/BerriAI/litellm/pull/28337)
- fix: serialize guardrail\_response to JSON in OTEL traces by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28362](https://github.com/BerriAI/litellm/pull/28362)
- chore(ci): merge dev branch by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28314](https://github.com/BerriAI/litellm/pull/28314)
- test(realtime): expect session.created as xAI realtime initial event by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28424](https://github.com/BerriAI/litellm/pull/28424)
- feat(tests): behavior-pinning harness + Key Tier-1 matrix by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28321](https://github.com/BerriAI/litellm/pull/28321)
- fix(proxy): hydrate wildcard discovery credentials ([#&#8203;28284](https://github.com/BerriAI/litellm/issues/28284)) - CCI Run by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28419](https://github.com/BerriAI/litellm/pull/28419)
- Litellm oss staging 04 21 2026 2 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;26569](https://github.com/BerriAI/litellm/pull/26569)
- chore(ci): merge dev branch by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28290](https://github.com/BerriAI/litellm/pull/28290)
- fix(vertex\_gemma): strip `context_management` from request body by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28438](https://github.com/BerriAI/litellm/pull/28438)
- fix(logging): recalculate cost after router retry failures by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;28476](https://github.com/BerriAI/litellm/pull/28476)
- fix(otel): emit guardrail span on violation, surface status + categories by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28364](https://github.com/BerriAI/litellm/pull/28364)
- test(proxy): behavior-pinning matrix for team management endpoints by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28441](https://github.com/BerriAI/litellm/pull/28441)
- test(vertex\_ai): tolerate transient 500 in google maps grounding test by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28503](https://github.com/BerriAI/litellm/pull/28503)
- fix(docker): restore npm to non\_root builder image by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28519](https://github.com/BerriAI/litellm/pull/28519)
- chore(ci): bump deps by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28524](https://github.com/BerriAI/litellm/pull/28524)
- build(deps-dev): bump black to 26.3.1 and apply formatting by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28525](https://github.com/BerriAI/litellm/pull/28525)
- chore(deps): bump deps by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28528](https://github.com/BerriAI/litellm/pull/28528)
- test(e2e): forward LITELLM\_LICENSE to UI e2e proxy by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28398](https://github.com/BerriAI/litellm/pull/28398)
- Add granian as a ASGI compliant web server. Provider better throughput stability, by [@&#8203;harish-berri](https://github.com/harish-berri) in [#&#8203;26027](https://github.com/BerriAI/litellm/pull/26027)
- Fix conflicts and UI by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28477](https://github.com/BerriAI/litellm/pull/28477)
- Add error\_description and hint for oauth flows by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28471](https://github.com/BerriAI/litellm/pull/28471)
- feat(mcp): Add tool call and tool list support via UI for Oauth mcps by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28454](https://github.com/BerriAI/litellm/pull/28454)
- feat(proxy): persist allowlisted OIDC claims in CLI SSO poll by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28463](https://github.com/BerriAI/litellm/pull/28463)
- fix(responses): use OpenAI SSEDecoder for Responses API streaming by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28566](https://github.com/BerriAI/litellm/pull/28566)
- Litellm oss staging 2 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28582](https://github.com/BerriAI/litellm/pull/28582)
- \[internal copy of [#&#8203;28269](https://github.com/BerriAI/litellm/issues/28269)] Codex cli jwt team alias by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28621](https://github.com/BerriAI/litellm/pull/28621)
- fix(check\_licenses): read PEP 639 license-expression metadata by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28529](https://github.com/BerriAI/litellm/pull/28529)
- test(proxy): behavior-pinning matrix for tier-2/3 key + team management endpoints by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28620](https://github.com/BerriAI/litellm/pull/28620)
- chore(test): remove dead old Playwright e2e suite by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28632](https://github.com/BerriAI/litellm/pull/28632)
- fix(sagemaker): send native Cohere embed payload to Cohere SageMaker endpoints by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;28613](https://github.com/BerriAI/litellm/pull/28613)
- style: apply black formatting to fix lint CI (LIT-3274) ([#&#8203;28639](https://github.com/BerriAI/litellm/issues/28639)) by [@&#8203;krrish-berri-2](https://github.com/krrish-berri-2) in [#&#8203;28641](https://github.com/BerriAI/litellm/pull/28641)
- fix(bedrock): decouple STS region from Bedrock aws\_region\_name by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;28245](https://github.com/BerriAI/litellm/pull/28245)
- test(streaming): tolerate Vertex 429 wrapped in MidStreamFallbackError by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28669](https://github.com/BerriAI/litellm/pull/28669)
- feat(guardrails): add Microsoft Purview DLP guardrail by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;24966](https://github.com/BerriAI/litellm/pull/24966)
- fix(mcp): forward upstream initialize instructions on cold gateway init by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;28231](https://github.com/BerriAI/litellm/pull/28231)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28680](https://github.com/BerriAI/litellm/pull/28680)
- CI: copy of [#&#8203;25177](https://github.com/BerriAI/litellm/issues/25177) (OCI GenAI: embeddings, streaming/reasoning fixes, model catalog) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28223](https://github.com/BerriAI/litellm/pull/28223)
- Encrypt callback\_vars in key/team metadata in DB by [@&#8203;Michael-RZ-Berri](https://github.com/Michael-RZ-Berri) in [#&#8203;27141](https://github.com/BerriAI/litellm/pull/27141)
- perf: reduce per-request and per-chunk overhead across Anthropic streaming hot paths by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28289](https://github.com/BerriAI/litellm/pull/28289)
- feat(azure): add Speech STT config support by [@&#8203;ishaan-berri](https://github.com/ishaan-berri) in [#&#8203;27482](https://github.com/BerriAI/litellm/pull/27482)
- test(proxy): phase-4 payload behavior pinning for tier-2/3 key + team management endpoints by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28681](https://github.com/BerriAI/litellm/pull/28681)
- feat(prometheus): emit per-token-type detail metrics (LIT-3220) ([#&#8203;28372](https://github.com/BerriAI/litellm/issues/28372)) by [@&#8203;ishaan-berri](https://github.com/ishaan-berri) in [#&#8203;28378](https://github.com/BerriAI/litellm/pull/28378)
- fix(otel): stamp http.response.status\_code on all error responses by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28405](https://github.com/BerriAI/litellm/pull/28405)
- chore(ui): build ui by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28707](https://github.com/BerriAI/litellm/pull/28707)
- fix(helm): drop main- prefix from default image tag by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28710](https://github.com/BerriAI/litellm/pull/28710)
- test(model\_prices): allow audio\_transcription\_config in schema by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28708](https://github.com/BerriAI/litellm/pull/28708)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28709](https://github.com/BerriAI/litellm/pull/28709)
- fix(team): refresh team cache on team\_model\_add/delete (LIT-3244) by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28683](https://github.com/BerriAI/litellm/pull/28683)
- fix(ui/add-model): stop vertex\_ai-anthropic\_models from leaking into Anthropic dropdown by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28723](https://github.com/BerriAI/litellm/pull/28723)
- Fix spend logs v2 route permissions by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28705](https://github.com/BerriAI/litellm/pull/28705)
- fix(proxy): Bedrock Knowledge Base pass-through: preserve SigV4 headers and signed request body by [@&#8203;milan-berri](https://github.com/milan-berri) in [#&#8203;27526](https://github.com/BerriAI/litellm/pull/27526)
- chore(tests): migrate Bedrock CI to AWS account [`9412775`](https://github.com/BerriAI/litellm/commit/941277531214) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28728](https://github.com/BerriAI/litellm/pull/28728)
- fix(otel): export SERVER span on management-endpoint success without http\_request by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28794](https://github.com/BerriAI/litellm/pull/28794)
- chore(ci): merge dev branch by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28801](https://github.com/BerriAI/litellm/pull/28801)
- chore(ci): merge dev branch by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28657](https://github.com/BerriAI/litellm/pull/28657)
- fix(ui): show 2-decimal precision for max\_budget on key overview by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28809](https://github.com/BerriAI/litellm/pull/28809)
- feat(proxy): allow `llm_api_routes` virtual keys to list MCP servers by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28442](https://github.com/BerriAI/litellm/pull/28442)
- chore(ci): merge dev branch by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28807](https://github.com/BerriAI/litellm/pull/28807)
- fix(team): keep team\_alias cache in sync on \_cache\_team\_object writes by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28737](https://github.com/BerriAI/litellm/pull/28737)
- chore(ci): merge dev branch by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28822](https://github.com/BerriAI/litellm/pull/28822)
- ci: daily oss-agent-shin canonical branch by [@&#8203;ishaan-berri](https://github.com/ishaan-berri) in [#&#8203;28829](https://github.com/BerriAI/litellm/pull/28829)
- test(proxy): add harness for proxy\_server.py behavior-pinning by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28827](https://github.com/BerriAI/litellm/pull/28827)
- feat(openai): apply regional-processing cost uplift for EU/US data residency by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28626](https://github.com/BerriAI/litellm/pull/28626)
- chore(admin-ui): regenerate static export with trailingSlash: true by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28112](https://github.com/BerriAI/litellm/pull/28112)
- fix(azure): preserve AD token refresh in v1 OpenAI client path by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28627](https://github.com/BerriAI/litellm/pull/28627)
- fix(ui): route API Reference back to query-param page by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28726](https://github.com/BerriAI/litellm/pull/28726)
- fix(model-edit): allow clearing custom pricing on wildcard models by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28719](https://github.com/BerriAI/litellm/pull/28719)
- fix(tests/vcr): make Redis cassette cache replay deterministically (zero VCR misses on consecutive runs) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28826](https://github.com/BerriAI/litellm/pull/28826)
- fix(proxy): strip LiteLLM policy tracking from OpenAI batch metadata by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;28425](https://github.com/BerriAI/litellm/pull/28425)
- Litellm OpenAI double prefix bug by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;28661](https://github.com/BerriAI/litellm/pull/28661)
- Litellm oss staging 250526 by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28770](https://github.com/BerriAI/litellm/pull/28770)
- fix(bedrock): align toolUse/toolSpec names and allow hyphens by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28874](https://github.com/BerriAI/litellm/pull/28874)
- fix(realtime): send TEXT frames and valid guardrail session.update by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28848](https://github.com/BerriAI/litellm/pull/28848)
- fix(mcp): extend key access-group union to MCP servers by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28890](https://github.com/BerriAI/litellm/pull/28890)
- fix(galileo): support hosted v2 spans API and string output extraction by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28771](https://github.com/BerriAI/litellm/pull/28771)
- fix(proxy): exclude proxy\_server\_request from its own body snapshot by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;28618](https://github.com/BerriAI/litellm/pull/28618)
- \[Feat] Add tool calling support for gemini and vertex ai live api by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;26590](https://github.com/BerriAI/litellm/pull/26590)
- refactor(ui): remove dead App Router scaffolding in (dashboard)/\* by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28891](https://github.com/BerriAI/litellm/pull/28891)
- fix(docker): use system Node in componentized builders + retry apk add by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28888](https://github.com/BerriAI/litellm/pull/28888)
- docs(agents): require consent before writing new third-party names by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;28908](https://github.com/BerriAI/litellm/pull/28908)
- refactor(ui): extract auth state into AuthContext by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28910](https://github.com/BerriAI/litellm/pull/28910)
- fix(mcp): resolve team.access\_group\_ids → MCP servers by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28997](https://github.com/BerriAI/litellm/pull/28997)
- test(ui): e2e cover team model edit + admin identity in navbar by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;28652](https://github.com/BerriAI/litellm/pull/28652)
- test(e2e): cover add-fallback flow in Router Settings by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29069](https://github.com/BerriAI/litellm/pull/29069)
- test(e2e): cover Team-BYOK add-model flow as proxy admin by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29068](https://github.com/BerriAI/litellm/pull/29068)
- fix(containers): record ownership for service-account keys + fix Prisma Json serialization by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28990](https://github.com/BerriAI/litellm/pull/28990)
- test(e2e): cover add-MCP-server flow via discovery → custom form by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29070](https://github.com/BerriAI/litellm/pull/29070)
- test(e2e): cover AI Hub make-public flow and public model\_hub\_table by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29071](https://github.com/BerriAI/litellm/pull/29071)
- \[internal copy of [#&#8203;28877](https://github.com/BerriAI/litellm/issues/28877)] feat: add support for claude code goal mode for bedrock opus output config by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;28898](https://github.com/BerriAI/litellm/pull/28898)
- feat(guardrails): wire apply\_guardrail into proxy logging callbacks by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28970](https://github.com/BerriAI/litellm/pull/28970)
- chore(ci): merge dev brach by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29192](https://github.com/BerriAI/litellm/pull/29192)
- perf(streaming): cut per-chunk overhead \~30% on Anthropic + Bedrock hot path by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28720](https://github.com/BerriAI/litellm/pull/28720)
- fix(proxy): enforce tag budgets for key-level tags by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29108](https://github.com/BerriAI/litellm/pull/29108)
- fix(vertex-ai): use DB credentials in video handlers + implement Veo video edit by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29098](https://github.com/BerriAI/litellm/pull/29098)
- fix(datadog): drain cost-management queue + opt-in FinOps tag allowlist by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;28487](https://github.com/BerriAI/litellm/pull/28487)
- feat(helm): split per-component ServiceAccounts for gateway, backend, and UI by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28712](https://github.com/BerriAI/litellm/pull/28712)
- chore(ci): bump deps ([#&#8203;29208](https://github.com/BerriAI/litellm/issues/29208)) by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29226](https://github.com/BerriAI/litellm/pull/29226)
- fix(tests/vcr): mint Google OAuth tokens live to prevent stale-token replay by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29229](https://github.com/BerriAI/litellm/pull/29229)
- chore(cookbook): bump Go directive to 1.26.3 in gollem example by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29234](https://github.com/BerriAI/litellm/pull/29234)
- chore(ci): bump version by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29242](https://github.com/BerriAI/litellm/pull/29242)
- feat(anthropic): add Claude Opus 4.8 and prune reasoning-effort flags by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29238](https://github.com/BerriAI/litellm/pull/29238)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29243](https://github.com/BerriAI/litellm/pull/29243)
- fix(ci): restore real Bedrock batch S3 bucket/role in oai\_misc\_config by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29245](https://github.com/BerriAI/litellm/pull/29245)
- fix(guardrails): persist disable\_global\_guardrails on keys by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29233](https://github.com/BerriAI/litellm/pull/29233)
- test(e2e): cover Team Admin view + member + key flows by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29072](https://github.com/BerriAI/litellm/pull/29072)
- docs: hand-written CLAUDE.md; remove AGENTS.md, point GEMINI.md at it by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29252](https://github.com/BerriAI/litellm/pull/29252)
- fix(teams): expose keys\_count on /v2/team/list and wire UI Resources badge by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;28502](https://github.com/BerriAI/litellm/pull/28502)
- fix(anthropic): stop injecting unsupported output\_config.effort=xhigh for Claude Code on Sonnet/Opus 4.6 by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29304](https://github.com/BerriAI/litellm/pull/29304)
- test(e2e): cover Internal Viewer nav, key, and team-info gating by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29075](https://github.com/BerriAI/litellm/pull/29075)
- test(e2e): cover Internal User key modal, team info, key page by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29074](https://github.com/BerriAI/litellm/pull/29074)
- test(e2e): cover navbar Logout flow as proxy admin by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29076](https://github.com/BerriAI/litellm/pull/29076)
- fix(mcp): resolve key.access\_group\_ids → MCP servers (ungated) by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29195](https://github.com/BerriAI/litellm/pull/29195)
- fix(router): enforce deployment budgets for dynamically added models by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29273](https://github.com/BerriAI/litellm/pull/29273)
- fix(proxy): map stripped batch body.model to proxy alias for auth by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29264](https://github.com/BerriAI/litellm/pull/29264)
- feat(mcp): support stateless and stateful clients via session-id routing by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;26857](https://github.com/BerriAI/litellm/pull/26857)
- fix(bedrock): support tool search results + chat annotations by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29120](https://github.com/BerriAI/litellm/pull/29120)
- fix(mcp): ignore stale ids on key save by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29128](https://github.com/BerriAI/litellm/pull/29128)
- feat(a2a): well-known agent-card discovery + LangGraph Platform mode by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28860](https://github.com/BerriAI/litellm/pull/28860)
- fix(proxy): link passthrough success spans to the SERVER root OTEL span by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29315](https://github.com/BerriAI/litellm/pull/29315)
- \[internal copy of [#&#8203;29089](https://github.com/BerriAI/litellm/issues/29089)] fix: duplicate claude code traces by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29311](https://github.com/BerriAI/litellm/pull/29311)
- feat(otel): typed semconv-aligned OpenTelemetry instrumentation by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;28909](https://github.com/BerriAI/litellm/pull/28909)
- tests(proxy\_server): surface current behavior in tests by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29309](https://github.com/BerriAI/litellm/pull/29309)
- test(e2e): cover Internal User create-key flow when in no teams by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29083](https://github.com/BerriAI/litellm/pull/29083)
- test(e2e): assert internal-user navbar identity is scoped to that user by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29077](https://github.com/BerriAI/litellm/pull/29077)
- feat(otel): add team\_metadata, http.route, and model names to inference spans by [@&#8203;yassin-berriai](https://github.com/yassin-berriai) in [#&#8203;29319](https://github.com/BerriAI/litellm/pull/29319)
- feat(context\_management): compact\_20260112 polyfill for non-Anthropic providers by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;28868](https://github.com/BerriAI/litellm/pull/28868)
- feat(enterprise): add RESEND\_FROM\_EMAIL for self-hosted Resend sends by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;28830](https://github.com/BerriAI/litellm/pull/28830)
- Revert Bedrock CI back to the reactivated AWS account ([`8886022`](https://github.com/BerriAI/litellm/commit/888602223428)) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29326](https://github.com/BerriAI/litellm/pull/29326)
- fix(mcp): preserve source\_url in GET /v1/mcp/server list responses by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29249](https://github.com/BerriAI/litellm/pull/29249)
- fix(mcp): preserve omitted fields on PUT /v1/mcp/server partial updates by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29253](https://github.com/BerriAI/litellm/pull/29253)
- fix(ci): make litellm\_internal\_staging green (logging test + Bedrock Opus 4.7 self-heal) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29344](https://github.com/BerriAI/litellm/pull/29344)
- refactor(proxy/auth): normalize Bearer prefix in safe-hash helper by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29343](https://github.com/BerriAI/litellm/pull/29343)
- test(reasoning-effort-grid): cover Claude Opus 4.8 across provider routes by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29327](https://github.com/BerriAI/litellm/pull/29327)
- fix(guardrails): return HTTP 400 for litellm content filter blocks by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;28418](https://github.com/BerriAI/litellm/pull/28418)
- fix(proxy): restrict vector store index create/delete to proxy admins by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29202](https://github.com/BerriAI/litellm/pull/29202)
- feat(pass\_through): extend passthrough\_managed\_object\_ids to Azure by [@&#8203;Sameerlite](https://github.com/Sameerlite) in [#&#8203;29160](https://github.com/BerriAI/litellm/pull/29160)
- fix(proxy): enforce allowed\_passthrough\_routes for auth=true pass-thr… by [@&#8203;shivamrawat1](https://github.com/shivamrawat1) in [#&#8203;29256](https://github.com/BerriAI/litellm/pull/29256)
- feat(mcp/auth): additive key access-group grants + opt-in member assignment by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29313](https://github.com/BerriAI/litellm/pull/29313)
- fix(reset\_budget): write only {spend, budget\_reset\_at} and stop pre-zeroing counter by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29358](https://github.com/BerriAI/litellm/pull/29358)
- test(e2e): cover PROXY\_LOGOUT\_URL redirect on Logout by [@&#8203;ryan-crabbe-berri](https://github.com/ryan-crabbe-berri) in [#&#8203;29080](https://github.com/BerriAI/litellm/pull/29080)
- fix(ui): break logout redirect loop across dev and proxy origins by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29360](https://github.com/BerriAI/litellm/pull/29360)
- fix(openai-moderation): wire streaming flags through to unified dispatcher by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;27324](https://github.com/BerriAI/litellm/pull/27324)
- chore(ci): build ui by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29366](https://github.com/BerriAI/litellm/pull/29366)
- fix(v3 limiter): cap no-max\_tokens TPM floor at smallest configured limit by [@&#8203;michelligabriele](https://github.com/michelligabriele) in [#&#8203;28805](https://github.com/BerriAI/litellm/pull/28805)
- fix(e2e): tolerate trailing slash in SERVER\_ROOT\_PATH login redirect by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29369](https://github.com/BerriAI/litellm/pull/29369)
- chore(deps): bump deps by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29373](https://github.com/BerriAI/litellm/pull/29373)
- chore(ci): promote internal staging to main by [@&#8203;yuneng-berri](https://github.com/yuneng-berri) in [#&#8203;29372](https://github.com/BerriAI/litellm/pull/29372)
- chore(release): patch v1.88.0-rc.1 with four staged fixes by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29632](https://github.com/BerriAI/litellm/pull/29632)
- chore(release): patch v1.88.0-rc.1 with [#&#8203;29612](https://github.com/BerriAI/litellm/issues/29612) (session-token budget-ceiling exemption) by [@&#8203;mateo-berri](https://github.com/mateo-berri) in [#&#8203;29637](https://github.com/BerriAI/litellm/pull/29637)
- fix(key\_generate): harden GHSA-q775 …
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…-1 spend (BerriAI#28110)

* fix(tests): stabilize image-edit VCR cassettes to stop live gpt-image-1 spend

The image-edit cassettes for ``gpt-image-1`` were accumulating >50
episodes and being refused by the persister
(``tests/_vcr_redis_persister.py``), so every CI run was hitting the
real OpenAI endpoint. The async parametrize was the clearest tell:
``test_openai_image_edit_litellm_sdk[True]`` cached to 1 entry, but the
``[False]`` (async) sibling grew to 51 entries and never replayed.

Two non-deterministic sources were fueling the growth, both fixed
here. After this patch, the cassettes settle at one episode per
unique call and replay for the 24-hour TTL like every other suite.

1. Pin httpx's multipart boundary at the source. The existing
   ``_normalize_multipart_boundary`` rewrites the boundary in the
   ``Content-Type`` header reliably, but on the async transport path
   the body is not always a contiguous ``bytes`` object when
   ``before_record_request`` runs, so the body-side replacement
   silently no-ops and the recorded cassette retains the random
   ``boundary=<hex>`` string. The next CI run gets a fresh random
   boundary, the ``safe_body`` matcher misses, and
   ``record_mode="new_episodes"`` appends another episode. Wrapping
   ``httpx._multipart.MultipartStream.__init__`` so it always uses
   ``vcr-static-boundary`` when no boundary is supplied eliminates
   the variance for both sync and async paths and leaves the normalizer
   in place as a backstop. Exposed as
   ``pin_httpx_multipart_boundary`` so other multipart-heavy suites
   (audio, ocr, batches) can adopt the same fixture later.

2. Pass raw ``bytes`` (not ``BytesIO`` streams) through the
   image-edit fixtures. A ``BytesIO`` whose file pointer is at EOF
   after the first multipart upload silently encodes an empty image on
   the next SDK / Router retry — yet another divergent body that VCR
   records as a new episode. ``bytes`` are immutable and position-less,
   so retries re-encode an identical payload every time. This is also
   a small production-correctness improvement: a customer passing
   ``BytesIO`` today would hit the same empty-body retry bug. The
   BytesIO-specific smoke test
   (``test_openai_image_edit_with_bytesio``) is preserved by giving
   ``get_test_images_as_bytesio`` its own factory instead of aliasing
   the bytes one.

3. Add ``scripts/flush_image_edit_vcr_cassettes.py`` — a one-shot
   Redis SCAN/DEL helper that clears the bloated pre-fix cassettes
   under ``litellm:vcr:cassette:tests/image_gen_tests/test_image_edits/*``.
   Without this, the next CI run still loads the existing 51-entry
   cassette, the new fixed-boundary body still doesn't match any of
   the stale entries, the persister still refuses to save, and the
   bleed continues. Run once with the production
   ``CASSETTE_REDIS_URL`` after merge (dry-run by default).

* DIAGNOSTIC: log VCR body mismatches + per-episode body hashes

Temporary observability boost so we can root-cause why
``test_image_edits.py`` async parametrizes still record fresh
episodes on every CI run even though the multipart boundary is now
pinned (sync parametrizes cache cleanly as VCR HIT). The matcher
currently raises ``AssertionError("request bodies differ")`` with
zero context, so we cannot tell whether the live body genuinely
varies, the matcher is comparing a bytes object to a stream object,
or the normalizer is silently skipping the body because it is not
bytes/str.

Three logs added; the first two are worth keeping permanently, the
third is intended to be reverted after the diagnosis lands:

1. ``_safe_body_matcher`` now emits a structured stderr block on
   mismatch (type of each side, length, SHA-256, first divergent
   byte offset, ±100-byte window). Always-on -- mismatches are
   signal, not noise, and the existing per-test verdict already
   logs once per test. PERMANENT.

2. ``_normalize_multipart_boundary`` now logs to stderr when the
   body type is not bytes/bytearray/str -- the silent ``else:
   return`` branch was masking exactly the case we suspect is
   firing on async (httpx ``MultipartStream`` handed to vcrpy
   before the body is read). PERMANENT.

3. ``_RedisPersister.save_cassette`` now logs every episode's body
   SHA-256, length, and 120-byte preview at save time. This lets
   two consecutive CI runs be diffed: if the same test records a
   different hash run-to-run, the live body genuinely varies; if
   both runs record the same hash but the matcher still misses, the
   bug is in the matcher itself. TEMPORARY -- revert once the
   async variance is identified and fixed.

Once a single ``image_gen_testing`` CI run produces these logs,
revert this commit (or just the persister hash block) with a force
push so the cassette save path is not noisy in steady-state.

* DIAGNOSTIC: route VCR diagnostics through per-PID files (bypass xdist capture)

Re-push of the diagnostic logging from the previous commit, this
time wired so the output actually survives to the CI log. xdist
captures stdout/stderr from every passing test in the worker
process; the body-matcher and normalizer-skip diagnostics fire from
inside vcrpy machinery during the test, so for any test that
ultimately passes (which is all of them once the cassettes are
recorded), the diagnostic lines are silently swallowed.

Fix: write each diagnostic line to a per-PID file under
``test-results/vcr-diagnostics/<pid>.log`` instead of writing to
stderr. The controller's ``pytest_terminal_summary`` aggregates
those files and writes them through ``terminalreporter.write_line``,
which is not subject to per-test capture. As a bonus,
``test-results/`` is already collected by the ``store_test_results``
step in CircleCI, so the raw per-worker logs survive as build
artifacts even after the test session ends.

Three call sites updated:

1. ``_emit_body_mismatch_diagnostic`` (matcher) -- writes the
   structured type/length/sha/window block via ``vcr_diag_write_line``.
2. ``_normalize_multipart_boundary`` -- logs the silent-skip path
   (body not bytes/bytearray/str) the same way.
3. ``_maybe_log_episode_body_hashes`` (persister) -- replaces the
   ``_log.warning`` calls (which the root-logger config also
   swallows in CI) with ``vcr_diag_write_line``.

Image-gen conftest is the only suite wired to dump the aggregated
log at session end. Other suites can opt in by adding
``emit_vcr_diagnostic_log(terminalreporter)`` to their own
``pytest_terminal_summary``. The diagnostic dir is cleared at the
start of each session (controller-only) so a local rerun does not
mix output from prior runs.

Same revert plan as the previous diagnostic commit: keep the
matcher + normalizer skip diagnostics permanently (they only fire
on signal events), revert the persister body-hash dump once the
async variance is identified.

* fix(tests): coalesce iterable request bodies before matching/recording

Root cause of the residual async image-edit cassette leak. The
diagnostic run for ``ba3915d9`` printed:

  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='list_iterator' length=unknown sha256=N/A
    body[b]: type='list_iterator' length=unknown sha256=N/A

httpx's async transport hands vcrpy a ``request.body`` that is a
``list_iterator`` over multipart chunks rather than a contiguous
``bytes`` blob. Two consequences:

1. ``_safe_body_matcher`` compares the two iterator objects with
   ``==``, which is identity comparison for arbitrary iterators -
   semantically identical multipart bodies never compare equal, and
   ``record_mode="new_episodes"`` appends a new episode on every CI
   run until the cassette crosses ``MAX_EPISODES_PER_CASSETTE`` and
   the persister refuses to save (this is exactly what the OVERFLOW
   warning has been catching).
2. ``_normalize_multipart_boundary`` short-circuits its
   ``else: return`` branch because the body is neither bytes nor
   str, so any residual random boundary characters in the body bytes
   are never rewritten.

Sync requests do not hit this code path: httpx's sync transport
hands vcrpy a single ``bytes`` body, so ``==`` works and the
boundary normalizer runs as intended. That is why
``test_openai_image_edit_litellm_sdk[True]`` records to ``entries=1``
and replays cleanly while ``[False]`` (async) kept growing by one
episode per run.

Fix: add ``_materialize_iterable_body`` which coalesces an iterable
``request.body`` into ``bytes`` in-place. Call it from two places:

* The top of ``_before_record_request``, so the boundary normalizer
  and the cassette serializer both see bytes from then on.
* The top of ``_safe_body_matcher``, as defense in depth in case a
  future vcrpy code path invokes the matcher without first going
  through ``_before_record_request``.

The vcrpy ``Request`` is a wrapper used for matching and recording;
the underlying httpx transport sends its own request body
separately, so replacing the iterator on the vcrpy wrapper does
not starve the live HTTP send.

After this lands the async parametrizes should flip from
``[VCR MISS:RECORDED] entries=N+1`` to ``[VCR HIT] entries=N`` on
the next CI run, matching the sync side and dropping the residual
~$3/day to $0.

* fix(tests): handle bytes_iterator + never leave an exhausted body

Follow-up to 8e08272. The previous attempt at coalescing iterable
request bodies bailed out (``return`` without writing
``request.body``) whenever it could not classify the chunk type.
That was the wrong failure mode for one critical case: vcrpy
sometimes presents the body as ``iter(some_bytes)``, whose Python
type is ``bytes_iterator`` and which yields ``int`` byte values
(0-255), not byte chunks. The old code saw an ``int`` chunk, hit
the ``else: return`` branch, and left ``request.body`` pointing at
the now-exhausted iterator.

The post-fix diagnostic run made this loud:

  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='bytes_iterator' length=unknown sha256=N/A
    body[b]: type='bytes_iterator' length=unknown sha256=N/A

Every async image-edit test then ballooned from entries=2 to
entries=10 in that single CI run -- the exhausted iterator meant
the live multipart upload went out as an empty body, OpenAI
returned 400, the SDK + flaky retries fired, each retry got a
fresh iterator that my hook exhausted again, and ``new_episodes``
recorded each failed attempt as a new cassette episode.

This patch:

* Recognizes ``bytes_iterator`` (chunks are ``int``) and
  reconstructs the buffer via ``bytes(chunks)``.
* Keeps the existing ``list_iterator``-over-bytes-chunks handling
  via ``b"".join(...)``.
* **Always writes a bytes value back to ``request.body`` after
  consuming the iterator.** If the chunk shape is unrecognized,
  ``request.body`` is set to ``b""`` rather than left as an
  exhausted iterator. That is wrong in the sense of "we lost the
  body" but right in the sense of "the failure mode is now visible
  (live API call sends empty body and fails fast) instead of
  invisible (corrupt cassette grows silently)". Combined with the
  matcher diagnostic, any future regression in this code path will
  surface in the CI log immediately.

Local verification covers ``bytes_iterator``, ``list_iterator``
over bytes chunks, generator over bytes chunks, empty iterator,
already-bytes (idempotent), identical-content iterator equality
in the matcher (now matches), and differing-content iterator
inequality (still raises).

* fix(tests): clear vcrpy's sticky _was_iter flag so materialized bodies stay bytes

Actual root cause of the async image-edit cassette leak. The
previous diagnostic run produced this dead giveaway:

  [vcr-episode-body-hash] ... episode[0]: body type='bytes_iterator'
    is not bytes/bytearray/str -- cannot hash
  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='bytes_iterator' length=unknown sha256=N/A
    body[b]: type='bytes_iterator' length=unknown sha256=N/A

Both sides of the matcher were ``bytes_iterator`` **after** the
materializer had supposedly converted them to bytes. That made no
sense until I read vcrpy's ``Request`` class.

vcrpy's ``Request`` keeps two private flags that are set in
``__init__`` from the original body's type and **never cleared by
the setter**:

  def __init__(self, method, uri, body, headers):
      self._was_file = hasattr(body, "read")
      self._was_iter = _is_nonsequence_iterator(body)
      ...

  @Property
  def body(self):
      if self._was_file: return BytesIO(self._body)
      if self._was_iter: return iter(self._body)
      return self._body

  @body.setter
  def body(self, value):
      if isinstance(value, str): value = value.encode("utf-8")
      self._body = value   # <-- does NOT touch _was_iter / _was_file

So when httpx's async transport hands vcrpy an iterator body,
``_was_iter`` becomes ``True`` and stays there forever. Even after
``_materialize_iterable_body`` writes plain bytes via
``request.body = out``, the next read of ``.body`` re-wraps the
stored bytes in ``iter()`` -- producing a fresh ``bytes_iterator``
that compares unequal to any other ``bytes_iterator`` via object
identity. The matcher missed every time, the cassette grew by one
episode per run, and the persister saw the same iterator type when
trying to hash the body for the diagnostic log.

Fix: after writing the materialized bytes, also force
``_was_iter`` and ``_was_file`` to ``False``. vcrpy exposes no
public API for this, so we touch the private flags directly --
acknowledged as a pragmatic test-only hack with a clear unit
boundary (the only call site is ``_materialize_iterable_body``).

Local repro reproduces the exact production setup:
``Request('POST', url, iter(b'multipart-content'), {})`` on two
sides, runs the matcher, asserts HIT. Verified the matcher hits on
identical content and still raises on differing content.

Should be the last fix needed. Existing cassettes that contain
oddly-shaped bodies (lists of int chunks, etc. from the previous
``_was_iter=True`` save path) still match because the materializer
canonicalises both sides to bytes before comparison -- no fourth
re-flush required.

* revert(tests): drop the temp per-episode body-hash diagnostic

Removed now that 1c51ad1 has confirmed the root cause (vcrpy's
sticky ``_was_iter`` flag making the body getter re-wrap stored
bytes in ``iter()`` on every access). The hash dump did its job --
the post-1c51ad13 image_gen_testing run shows all five async
image-edit tests as ``[VCR HIT]`` with stable entry counts and
zero billing errors -- and is too noisy to keep on by default
(over 100 lines per session at steady state).

Kept permanently:

* ``_safe_body_matcher`` mismatch diagnostic in
  ``_vcr_conftest_common.py``. Only fires on a body mismatch,
  which is signal worth surfacing whenever it happens.
* ``_normalize_multipart_boundary`` "skipped" log line. Same
  rationale -- only fires when the body shape is something the
  normalizer cannot rewrite in place.
* The ``test-results/vcr-diagnostics/<pid>.log`` per-PID file
  plumbing (``vcr_diag_write_line`` /
  ``emit_vcr_diagnostic_log``). Useful for any future diagnostic
  that needs to bypass xdist stdout/stderr capture; cheap to keep.

* chore(tests): delete unused flush script + wire VCR diagnostic dump everywhere

* Remove ``scripts/flush_image_edit_vcr_cassettes.py``. It was a
  one-shot helper for the initial cassette flush; the iterator and
  ``_was_iter`` fixes mean no future flush should be required, and
  the script was never run anywhere (the actual flushes happened
  inside the CI conftest via the temp hacks that have since been
  reverted).

* The matcher mismatch + normalizer skip diagnostics already write
  per-PID files for every suite that imports the shared VCR
  plumbing, but ``emit_vcr_diagnostic_log`` -- the controller-side
  dump that surfaces those files into the CI log at session end --
  was only wired into ``image_gen_tests``. Add the one-line call to
  the 12 sibling conftests that already use VCR so the diagnostics
  surface in any suite's terminal output if a body matcher ever
  misses. No new output in steady state -- the dump is a no-op when
  no diagnostics were recorded that session.

* chore(tests): trim non-essential comments per project comment policy

Strips docstrings, inline comments, and block comments that this PR
introduced where the code itself was already self-evident. Keeps the
few lines that document non-obvious behaviour (raw-bytes-not-BytesIO
rationale on the image fixtures, the per-PID-files-bypass-xdist note
on the diagnostic directory). Touches only comments this PR added --
no pre-existing comment is removed.

Net: -161 lines of comment/docstring across 3 files, no code
behaviour change.

* chore(tests): forward **kwargs in pin_httpx_multipart_boundary wrapper

Defensive against future httpx MultipartStream.__init__ adding new
optional kwargs. Without the forward, the wrapper would silently drop
them. No behaviour change today.

* chore(tests): canonicalize VCR matchers and surface shouldn't-happen branches

Bundles the "follow-up cleanup PR" into this one so it does not get
lost. Four small changes:

1. Introduce ``_canonical_body(req) -> (bytes, pre_type)`` and route
   ``_safe_body_matcher`` through it. The matcher now operates on
   bytes by construction; the "compare two iterator objects via
   ``==`` and silently get object-identity semantics" failure mode
   (which cost us this entire PR to diagnose) is structurally
   impossible to reintroduce. ``pre_type`` is the body type *before*
   canonicalization, surfaced by the mismatch diagnostic so a future
   regression involving a new body shape is still visible.

2. Add a structured diagnostic to ``_key_fingerprint_matcher``. It
   was previously raising a bare ``AssertionError("API key
   fingerprints differ")`` with zero context -- exactly the
   anti-pattern the body matcher had before this PR.

3. Surface "shouldn't-happen" branches via ``vcr_diag_write_line``:

   * ``_strip_image_b64_payloads`` -- logs when ``response``,
     ``response['body']``, or ``response['body']['string']`` arrives
     in an unexpected shape (vcrpy contract violation).
   * ``_compute_key_fingerprint`` -- logs the ``"no-key"`` fallback
     with the request method/URL so a stripped-auth-header bug is
     visible instead of masked.
   * ``_canonical_body`` -- logs its own empty-bytes fallback when a
     body has a shape ``_materialize_iterable_body`` did not handle.

4. Re-introduce per-episode body-hash logging in
   ``_RedisPersister.save_cassette`` (was reverted in 927c554 as
   "noisy"). Quantified cost: ~25 KB of CI log per session at peak,
   ~ms-scale CPU, zero output in steady state (no save = no log).
   Trade-off favours keeping it: lets two consecutive CI runs be
   diffed by body hash, which is how we will spot the next regression
   in the same class.

All call sites still work: local repro confirms iter==iter HIT,
iter!=iter raises, plain-bytes HIT, body-hash log emits via the same
per-PID file plumbing as the matcher diagnostics.

* chore(tests): symmetrize diag-log cleanup across every VCR-using conftest

``image_gen_tests/conftest.py`` was the only suite that cleared
``test-results/vcr-diagnostics/*.log`` at session start. The other 12
VCR-using conftests inherited any stale per-PID logs from a previous
local run and would dump them in the terminal summary -- harmless in
CI (fresh container) but confusing locally when running multiple
suites in sequence.

Extracts the cleanup into a ``reset_vcr_diag_dir`` helper in
``tests/_vcr_conftest_common.py`` and calls it from every VCR-using
conftest's ``pytest_configure``. Same single source of truth, no
inline duplication.

* fix(tests): gate body materialization on __next__ and strip PR comments

aiohttp/vcrpy stores the json kwarg as a dict; _materialize_iterable_body
was iterating it via __iter__ and joining the keys, replacing the request
body with concatenated key names ("textlanguageentities"). Gate on
__next__ so containers (dict/list/tuple) are left alone — only single-use
iterators like httpx's bytes_iterator / list_iterator are materialized.
Log diagnostic line when chunk type is unrecognized.

* fix(tests): JSON-encode dict bodies in canonical_body for stable matching

aiohttp stubs store the json kwarg as a dict; the fallback that compared
all dicts as b"" caused concurrent presidio analyze calls to be served
the wrong cassette episode. JSON-encode with sort_keys for stable bytes.

* fix(tests): guard emit_vcr_diagnostic_log against multi-conftest re-emission

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(tests): globalize multipart-boundary pin + stabilize whisper fixtures

Diagnostic shows audio_testing was silently re-recording 50+ live Whisper
episodes per CI run (over MAX_EPISODES_PER_CASSETTE, so the persister
refused to save). Two changes:

* Move the session-autouse _pin_multipart_boundary fixture into the
  shared _vcr_conftest_common module so every VCR-using suite picks it
  up via a single import. image_gen had it inline; the other 12 suites
  silently lacked it.
* Replace the module-level open("rb") audio file handles in test_whisper
  with cached bytes + a per-call (filename, bytes, mimetype) tuple,
  mirroring the image_edits raw-bytes pattern. Stops the file-pointer-
  at-EOF bug where the second test got an empty multipart body.

* chore(tests): drop per-episode body-hash dump and redundant emit guard

---------

Co-authored-by: shin-berri <shin-laptop@berri.ai>
Co-authored-by: yuneng-jiang <yuneng@berri.ai>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants