mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
feat(e2e): make Claude Code send the same bytes every build
The compat cells drove the CLI with a fresh HOME per invocation and the pytest process's own working directory, and both reach the request body. The system prompt names a memory directory built from $CLAUDE_CONFIG_DIR/projects/<cwd slug>, so a per-invocation config directory rewrote every body, and the CLI adds a git block for its working directory, so inheriting the checkout rewrote every body once per candidate. The device id churned for the same reason: the CLI mints it once and persists it in .claude.json, which we threw away each call. Nothing here was load-bearing. All three ride in metadata.user_id, whose job is abuse detection, not quota, caching or continuity. So pin the config directory and the working directory at fixed paths, seed the device id, and pin the session id. HOME stays fresh and empty per invocation, so the isolation is no weaker than before, and the CLI's own state no longer outlives the pod either. The working directory is deliberately not the checkout, so a model-directed Read now sees an empty directory rather than the repository. A pinned session id needs --no-session-persistence beside it: the CLI refuses a session id another live process holds, and the matrix runs its cells across xdist workers. Without the flag, six of eight concurrent invocations die on "Session ID is already in use".
This commit is contained in:
parent
7d42bc751d
commit
39acea0754
2 changed files with 200 additions and 0 deletions
|
|
@ -0,0 +1,143 @@
|
|||
"""The CLI must send the same request bytes from one build to the next.
|
||||
|
||||
Markerless harness test: it drives the real `claude` binary against a local
|
||||
stub instead of a proxy, so it carries no `e2e` marker. The binary is a
|
||||
prerequisite of this whole suite, so a missing one is a failure rather than a
|
||||
skip.
|
||||
|
||||
Two builds differ in ways the driver does not control: a fresh pod, so no CLI
|
||||
state survives, and a different candidate checked out at a different commit.
|
||||
Both used to reach the request body, through the memory path the system prompt
|
||||
names and through the git block the CLI adds for its working directory, so the
|
||||
shared provider cache missed on every Claude Code cell. This replays those two
|
||||
differences across a pair of invocations and holds the bytes equal.
|
||||
|
||||
A pinned session id is what makes the second test necessary. The matrix runs
|
||||
its cells across xdist workers, and the CLI refuses to start a session id that
|
||||
another live process already holds, so pinning one without also opting out of
|
||||
session persistence turns most of a parallel run red.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from claude_code.cli_driver import _stable_cli_state, run_claude
|
||||
from claude_code.rate_limiter import RateLimiter
|
||||
|
||||
_STUB_REPLY = {
|
||||
"id": "msg_stub",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-haiku-4-5",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 2},
|
||||
}
|
||||
|
||||
|
||||
def _make_repo(root: Path, subject: str) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
identity = {"NAME": "t", "EMAIL": "t@e2e"}
|
||||
env = dict(
|
||||
os.environ,
|
||||
**{f"GIT_{role}_{key}": value for role in ("AUTHOR", "COMMITTER") for key, value in identity.items()},
|
||||
)
|
||||
(root / "file.txt").write_text(subject, encoding="utf-8")
|
||||
for args in (["init", "-q"], ["add", "."], ["commit", "-q", "-m", subject]):
|
||||
subprocess.run(["git", *args], cwd=root, env=env, check=True, capture_output=True)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture(name="captured")
|
||||
def _captured() -> Tuple[str, List[bytes]]:
|
||||
bodies: List[bytes] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_POST(self) -> None:
|
||||
raw = self.rfile.read(int(self.headers.get("content-length") or 0))
|
||||
if "count_tokens" not in self.path:
|
||||
with lock:
|
||||
bodies.append(raw)
|
||||
payload = json.dumps({"input_tokens": 10} if "count_tokens" in self.path else _STUB_REPLY).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
return
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}", bodies
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def test_two_builds_send_the_same_request_bytes(captured: Tuple[str, List[bytes]], tmp_path: Path) -> None:
|
||||
base_url, bodies = captured
|
||||
limiter = RateLimiter(state_dir=tmp_path / "limiter")
|
||||
checkouts = (_make_repo(tmp_path / "build-1", "first"), _make_repo(tmp_path / "build-2", "second"))
|
||||
origin = Path.cwd()
|
||||
|
||||
sent = []
|
||||
for checkout in checkouts:
|
||||
shutil.rmtree(Path(_stable_cli_state()[0]).parent, ignore_errors=True)
|
||||
os.chdir(checkout)
|
||||
try:
|
||||
before = len(bodies)
|
||||
run_claude(
|
||||
prompt="say ok",
|
||||
model="claude-haiku-4-5",
|
||||
base_url=base_url,
|
||||
api_key="stub",
|
||||
extra_env={"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"},
|
||||
rate_limiter=limiter,
|
||||
)
|
||||
sent.append(bodies[before:])
|
||||
finally:
|
||||
os.chdir(origin)
|
||||
|
||||
assert sent[0], "the CLI sent no request to the stub, so there is nothing to compare"
|
||||
assert sent[0] == sent[1]
|
||||
|
||||
|
||||
def test_concurrent_cells_do_not_collide_on_the_pinned_session(
|
||||
captured: Tuple[str, List[bytes]], tmp_path: Path
|
||||
) -> None:
|
||||
base_url, bodies = captured
|
||||
limiter = RateLimiter(state_dir=tmp_path / "limiter")
|
||||
|
||||
def one(_index: int) -> int:
|
||||
return run_claude(
|
||||
prompt="say ok",
|
||||
model="claude-haiku-4-5",
|
||||
base_url=base_url,
|
||||
api_key="stub",
|
||||
extra_env={"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"},
|
||||
rate_limiter=limiter,
|
||||
).exit_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
codes = list(pool.map(one, range(4)))
|
||||
|
||||
assert codes == [0, 0, 0, 0]
|
||||
assert bodies, "the CLI sent no request to the stub, so there is nothing to compare"
|
||||
assert set(Counter(bodies).values()) == {4}
|
||||
|
|
@ -132,6 +132,57 @@ def _make_isolated_home() -> str:
|
|||
return tempfile.mkdtemp(prefix="claude-cli-home-")
|
||||
|
||||
|
||||
_FIXED_CLI_USER_ID = "0" * 64
|
||||
_FIXED_CLI_SESSION_ID = "00000000-0000-4000-8000-000000000000"
|
||||
|
||||
|
||||
def _seed_cli_identity(config_dir: str) -> None:
|
||||
"""Pin the device id the CLI would otherwise mint per config directory.
|
||||
|
||||
It mints 32 random bytes on first run, writes them to `.claude.json` as
|
||||
`userID`, and sends them in `metadata.user_id` forever after, so the value
|
||||
is stable for exactly as long as that file lives. Pinning it, and the
|
||||
session id passed beside it, costs nothing: both feed abuse detection
|
||||
rather than quota, caching or continuity."""
|
||||
path = os.path.join(config_dir, ".claude.json")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
if json.load(handle).get("userID") == _FIXED_CLI_USER_ID:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
staged = f"{path}.{os.getpid()}"
|
||||
with open(staged, "w", encoding="utf-8") as handle:
|
||||
json.dump({"userID": _FIXED_CLI_USER_ID}, handle)
|
||||
os.replace(staged, path)
|
||||
|
||||
|
||||
def _stable_cli_state() -> Tuple[str, str]:
|
||||
"""Config directory and working directory for the CLI, at fixed paths.
|
||||
|
||||
Both reach the request body. The memory directory the system prompt
|
||||
names is `$CLAUDE_CONFIG_DIR/projects/<cwd slug>/memory`, and a working
|
||||
directory inside a git repository also contributes its branch and recent
|
||||
commits. So a per-invocation config directory rewrites every body, and
|
||||
inheriting the checkout rewrites every body once per candidate, which is
|
||||
why the shared provider cache could never serve a Claude Code cell.
|
||||
Pinning both makes the bodies repeatable across builds.
|
||||
|
||||
This narrows what survives rather than widening it: HOME stays fresh and
|
||||
empty per invocation, so the isolation `_make_isolated_home` describes is
|
||||
unchanged, and the CLI's own state no longer outlives the pod either. The
|
||||
working directory is deliberately not the checkout, so a model-directed
|
||||
`Read` sees an empty directory instead of the repository.
|
||||
"""
|
||||
root = os.path.join(tempfile.gettempdir(), f"litellm-e2e-claude-{os.getuid()}")
|
||||
config_dir = os.path.join(root, "config")
|
||||
workspace = os.path.join(root, "workspace")
|
||||
for path in (root, config_dir, workspace):
|
||||
os.makedirs(path, mode=0o700, exist_ok=True)
|
||||
_seed_cli_identity(config_dir)
|
||||
return config_dir, workspace
|
||||
|
||||
|
||||
class ClaudeCLIError(RuntimeError):
|
||||
"""Raised when the `claude` CLI cannot be invoked or returns a fatal error."""
|
||||
|
||||
|
|
@ -222,6 +273,9 @@ def run_claude(
|
|||
"--verbose",
|
||||
"--model",
|
||||
model,
|
||||
"--session-id",
|
||||
_FIXED_CLI_SESSION_ID,
|
||||
"--no-session-persistence",
|
||||
]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
|
@ -244,6 +298,8 @@ def run_claude(
|
|||
# regardless of how the subprocess exits.
|
||||
isolated_home = _make_isolated_home()
|
||||
env["HOME"] = isolated_home
|
||||
config_dir, workspace = _stable_cli_state()
|
||||
env["CLAUDE_CONFIG_DIR"] = config_dir
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
|
|
@ -262,6 +318,7 @@ def run_claude(
|
|||
completed = run_fn(
|
||||
cmd,
|
||||
env=env,
|
||||
cwd=workspace,
|
||||
input=stdin_input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue