Skip to content

Commit 10d9daa

Browse files
committed
merge: output-shaper replays the pinned tail when shaping stops (headroomlabs-ai#2524)
2 parents d13e6ed + 9f4dbb8 commit 10d9daa

5 files changed

Lines changed: 228 additions & 48 deletions

File tree

.github/workflows/docker.yml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ jobs:
204204
- name: Upload digest marker
205205
uses: actions/upload-artifact@v7
206206
with:
207-
# Variant + arch in the artifact name so the manifest job can
208-
# download with `pattern: digests-<variant>-*` to gather all
209-
# arches for one variant. `root` substitutes the empty-string
210-
# variant since GHA artifact names can't end in a hyphen.
207+
# Variant + arch uniquely identify the marker. The manifest job
208+
# downloads both architecture artifacts by exact name; a glob such
209+
# as `digests-code-*` would also match code-nonroot/code-slim.
210+
# `root` substitutes the empty-string variant.
211211
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
212212
path: ${{ runner.temp }}/digests/*
213213
if-no-files-found: error
@@ -273,12 +273,17 @@ jobs:
273273
username: ${{ github.actor }}
274274
password: ${{ secrets.GITHUB_TOKEN }}
275275

276-
- name: Download per-arch digests for this variant
276+
- name: Download amd64 digest for this variant
277277
uses: actions/download-artifact@v8
278278
with:
279-
pattern: digests-${{ matrix.variant.name || 'root' }}-*
279+
name: digests-${{ matrix.variant.name || 'root' }}-amd64
280+
path: ${{ runner.temp }}/digests
281+
282+
- name: Download arm64 digest for this variant
283+
uses: actions/download-artifact@v8
284+
with:
285+
name: digests-${{ matrix.variant.name || 'root' }}-arm64
280286
path: ${{ runner.temp }}/digests
281-
merge-multiple: true
282287

283288
# Same tag rules as the pre-fan-out workflow — preserve every
284289
# tag flavor (semver, ref, sha-prefixed, version-suffixed,

.release-please-config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"bump-patch-for-minor-pre-major": false,
88
"draft": false,
99
"prerelease": false,
10-
"separate-pull-requests": false,
11-
"pull-request-title-pattern": "chore: release ${version}",
10+
"separate-pull-requests": true,
11+
"pull-request-title-pattern": "chore: release${component} ${version}",
1212
"packages": {
1313
".": {
1414
"package-name": "headroom-ai",

headroom/proxy/handlers/anthropic.py

Lines changed: 61 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2988,18 +2988,32 @@ def _count_tool_tokens(value: object) -> int:
29882988
)
29892989
from headroom.proxy.output_shaper import (
29902990
OutputShaperSettings,
2991+
apply_verbosity_steering,
29912992
classify_turn,
29922993
resolve_verbosity_level,
29932994
shape_request,
29942995
)
29952996

2997+
# The level an established frozen prefix was built at outranks
2998+
# the CURRENT configuration. Dropping the tail from a prefix
2999+
# built with it busts the whole provider cache exactly like
3000+
# appending one to a prefix built without it — so the pin is
3001+
# read before the enablement and arm gates and replayed below
3002+
# when those no longer hold (shaper switched off live, rollout
3003+
# channel moved, holdout drift into control). Only an explicit
3004+
# request bypass, handled by the enclosing guard, may sacrifice
3005+
# the cache.
3006+
_pinned = getattr(prefix_tracker, "output_shaping_level", None)
3007+
_pinned_established = frozen_message_count > 0 and _pinned is not None
3008+
29963009
_shaper_settings = OutputShaperSettings.from_env(
29973010
enabled=(
29983011
self.config.rollout.is_enabled("proxy_output_shaper")
29993012
if getattr(self.config, "rollout", None) is not None
30003013
else None
30013014
)
30023015
)
3016+
_arm = "control"
30033017
if _shaper_settings.enabled:
30043018
# Conversation-stable holdout assignment: a whole
30053019
# conversation is treatment or control. This keeps the A/B
@@ -3027,48 +3041,56 @@ def _count_tool_tokens(value: object) -> int:
30273041
# outcome funnel can feed the savings ledger from any path.
30283042
transforms_applied.append(stratum_label(_arm, _stratum))
30293043

3030-
if _arm == "treatment":
3031-
# The system prompt is the head of the provider's cache
3032-
# prefix, so the shaping tail has to stay byte-identical
3033-
# for every turn of a conversation whose frozen prefix
3034-
# is established. All three drifts invalidate that whole
3035-
# prefix: appending the tail to a prefix built without
3036-
# it, dropping the tail from one built with it, and
3037-
# swapping it for another level's text. The level the
3038-
# conversation was established at is therefore pinned on
3039-
# the tracker and replayed until the prefix thaws.
3040-
_pinned = getattr(prefix_tracker, "output_shaping_level", None)
3041-
_level, _src = resolve_verbosity_level(_shaper_settings)
3042-
if frozen_message_count > 0 and _pinned is None:
3044+
if _shaper_settings.enabled and _arm == "treatment":
3045+
# The system prompt is the head of the provider's cache
3046+
# prefix, so the shaping tail has to stay byte-identical
3047+
# for every turn of a conversation whose frozen prefix
3048+
# is established. All three drifts invalidate that whole
3049+
# prefix: appending the tail to a prefix built without
3050+
# it, dropping the tail from one built with it, and
3051+
# swapping it for another level's text. The level the
3052+
# conversation was established at is therefore pinned on
3053+
# the tracker and replayed until the prefix thaws.
3054+
_level, _src = resolve_verbosity_level(_shaper_settings)
3055+
if frozen_message_count > 0 and _pinned is None:
3056+
logger.info(
3057+
f"[{request_id}] OutputShaper: skipped — frozen "
3058+
f"prefix ({frozen_message_count} messages) predates "
3059+
"the shaping tail; preserving provider cache"
3060+
)
3061+
else:
3062+
if _pinned_established and _pinned != _level:
30433063
logger.info(
3044-
f"[{request_id}] OutputShaper: skipped — frozen "
3045-
f"prefix ({frozen_message_count} messages) predates "
3046-
"the shaping tail; preserving provider cache"
3064+
f"[{request_id}] OutputShaper: L{_level}/{_src} "
3065+
f"pinned to L{_pinned} — the frozen prefix "
3066+
f"({frozen_message_count} messages) carries "
3067+
"that tail"
30473068
)
3048-
else:
3049-
if (
3050-
frozen_message_count > 0
3051-
and _pinned is not None
3052-
and _pinned != _level
3053-
):
3054-
logger.info(
3055-
f"[{request_id}] OutputShaper: L{_level}/{_src} "
3056-
f"pinned to L{_pinned} — the frozen prefix "
3057-
f"({frozen_message_count} messages) carries "
3058-
"that tail"
3059-
)
3060-
_level, _src = _pinned, "pinned"
3061-
shape_result = shape_request(
3062-
body, _shaper_settings, level_override=_level
3069+
_level, _src = _pinned, "pinned"
3070+
shape_result = shape_request(body, _shaper_settings, level_override=_level)
3071+
prefix_tracker.output_shaping_level = _level
3072+
if shape_result.changed:
3073+
body_mutation_tracker.mark_mutated("output_shaper")
3074+
transforms_applied.extend(shape_result.labels or [])
3075+
logger.info(
3076+
f"[{request_id}] OutputShaper(L{_level}/{_src}): "
3077+
f"{shape_result.labels}"
30633078
)
3064-
prefix_tracker.output_shaping_level = _level
3065-
if shape_result.changed:
3066-
body_mutation_tracker.mark_mutated("output_shaper")
3067-
transforms_applied.extend(shape_result.labels or [])
3068-
logger.info(
3069-
f"[{request_id}] OutputShaper(L{_level}/{_src}): "
3070-
f"{shape_result.labels}"
3071-
)
3079+
elif _pinned_established:
3080+
# Replay only. The conversation is no longer being shaped
3081+
# (disabled, or control), but its frozen prefix already
3082+
# carries the L{_pinned} tail, so those exact bytes have to
3083+
# keep going out. Deliberately narrower than the treatment
3084+
# path: no experiment label (attribution belongs to the live
3085+
# assignment, not to a cache replay) and no effort routing
3086+
# (that lever never enters the cached prefix).
3087+
if _pinned > 0 and apply_verbosity_steering(body, _pinned):
3088+
body_mutation_tracker.mark_mutated("output_shaper")
3089+
logger.info(
3090+
f"[{request_id}] OutputShaper: replaying pinned L{_pinned} — "
3091+
f"the frozen prefix ({frozen_message_count} messages) carries "
3092+
"that tail; shaping is off or in control"
3093+
)
30723094

30733095
# Unit 2: mark end of pre-upstream phase. Everything after this
30743096
# point is upstream I/O or post-response bookkeeping.

tests/test_proxy_anthropic_cache_stability.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,109 @@ def _post() -> None:
671671
assert fake_tracker.output_shaping_level == 2
672672

673673

674+
@pytest.mark.parametrize("drift", ["shaper_disabled", "arm_control"])
675+
def test_output_shaper_replays_pinned_tail_when_shaping_stops(monkeypatch, drift: str) -> None:
676+
"""Shaping stopping mid-conversation must not drop an established tail.
677+
678+
The pin is consulted before the enablement and arm gates: a conversation
679+
that froze with an L2 tail keeps sending those exact system bytes even once
680+
the shaper is disabled or its arm drifts into control. Dropping the tail
681+
invalidates the whole provider prefix, the same bust as adding one.
682+
Experiment attribution stays with the live assignment — a replay never
683+
records a treatment label.
684+
"""
685+
from headroom.proxy import runtime_env
686+
from headroom.proxy.output_savings_policy import _STRATUM_LABEL
687+
from headroom.proxy.output_verbosity_policy import STEERING_SENTINEL
688+
from headroom.rollout import resolve_rollout
689+
690+
runtime_env.clear_overrides()
691+
monkeypatch.setenv("HEADROOM_OUTPUT_SHAPER", "1")
692+
# Rollout-gated as in the tests above: beta is required for the shaper to
693+
# run at all.
694+
monkeypatch.setenv("HEADROOM_ROLLOUT_CHANNEL", "beta")
695+
monkeypatch.setenv("HEADROOM_VERBOSITY_LEVEL", "2")
696+
697+
captured = {}
698+
with _make_proxy_client() as client:
699+
proxy = client.app.state.proxy
700+
proxy.config.optimize = False
701+
proxy.config.image_optimize = False
702+
proxy.config.ccr_proactive_expansion = False
703+
704+
fake_tracker = _FakePrefixTracker(frozen_count=0)
705+
proxy.session_tracker_store.compute_session_id = (
706+
lambda request, model, messages, **_kwargs: "stable-session"
707+
)
708+
proxy.session_tracker_store.get_or_create = lambda session_id, provider: fake_tracker
709+
710+
async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # noqa: ANN001
711+
captured["body"] = body
712+
return httpx.Response(
713+
200,
714+
json={
715+
"id": "msg_shape_3",
716+
"type": "message",
717+
"role": "assistant",
718+
"content": [{"type": "text", "text": "ok"}],
719+
"usage": {
720+
"input_tokens": 20,
721+
"output_tokens": 3,
722+
"cache_read_input_tokens": 0,
723+
"cache_creation_input_tokens": 0,
724+
},
725+
},
726+
)
727+
728+
proxy._retry_request = _fake_retry
729+
730+
def _post() -> str:
731+
response = client.post(
732+
"/v1/messages",
733+
headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"},
734+
json={
735+
"model": "claude-sonnet-4-6",
736+
"max_tokens": 64,
737+
"system": "base system",
738+
"messages": [
739+
{"role": "user", "content": "frozen prefix"},
740+
{"role": "assistant", "content": "ack"},
741+
{"role": "user", "content": "latest user"},
742+
],
743+
},
744+
)
745+
assert response.status_code == 200
746+
return response.headers.get("x-headroom-transforms", "")
747+
748+
# Turn 1: shaped and pinned, establishing the L2 system bytes.
749+
transforms = _post()
750+
established = captured["body"]["system"]
751+
assert STEERING_SENTINEL in str(established)
752+
assert fake_tracker.output_shaping_level == 2
753+
assert _STRATUM_LABEL in transforms
754+
755+
# Turn N: the prefix is frozen and shaping stops for this conversation.
756+
fake_tracker._frozen_count = 1
757+
if drift == "shaper_disabled":
758+
# Enablement is a resolved snapshot since #1490, so a live disable
759+
# reaches the handler as a re-resolved gate with the legacy alias
760+
# falsey — the same route a rollout-channel demotion takes.
761+
proxy.config.rollout = resolve_rollout(
762+
{"HEADROOM_ROLLOUT_CHANNEL": "beta", "HEADROOM_OUTPUT_SHAPER": "0"}
763+
)
764+
else:
765+
monkeypatch.setenv("HEADROOM_OUTPUT_HOLDOUT", "1")
766+
transforms = _post()
767+
assert captured["body"]["system"] == established
768+
assert fake_tracker.output_shaping_level == 2
769+
770+
# The treatment path is unreachable in both drifts, so surviving bytes
771+
# can only have come from the replay. The missing treatment label is
772+
# what proves the drift actually took effect — and that a cache replay
773+
# is never counted as a treatment observation.
774+
assert _STRATUM_LABEL not in transforms
775+
776+
674777
def test_ccr_system_instruction_injection_disabled_when_prefix_frozen(monkeypatch) -> None:
675778
captured = {"inject_system": None}
676779
with _make_proxy_client() as client:

tests/test_release_workflows.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,40 @@ def test_docker_latest_promotion_is_owned_by_root_manifest_cell() -> None:
6868
assert guard_start < manifest_script.index("exit 1", guard_start) < create_start
6969

7070

71+
def test_docker_manifest_downloads_exactly_one_artifact_per_architecture() -> None:
72+
"""Each manifest cell must download exactly its two architecture digests.
73+
74+
Keeping the variant before a trailing wildcard makes prefix-related names
75+
overlap: ``digests-code-*`` also selects code-nonroot, code-slim, and
76+
code-slim-nonroot. The 0.35.0 Docker release exposed this by downloading
77+
eight markers into the code manifest job instead of two.
78+
"""
79+
workflow = yaml.safe_load((ROOT / ".github" / "workflows" / "docker.yml").read_text())
80+
jobs = workflow["jobs"]
81+
build = jobs["docker-build"]
82+
manifest = jobs["docker-manifest"]
83+
upload = next(step for step in build["steps"] if step.get("name") == "Upload digest marker")
84+
downloads = [
85+
step
86+
for step in manifest["steps"]
87+
if step.get("name")
88+
in {
89+
"Download amd64 digest for this variant",
90+
"Download arm64 digest for this variant",
91+
}
92+
]
93+
94+
assert upload["with"]["name"] == (
95+
"digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}"
96+
)
97+
assert [step["with"]["name"] for step in downloads] == [
98+
"digests-${{ matrix.variant.name || 'root' }}-amd64",
99+
"digests-${{ matrix.variant.name || 'root' }}-arm64",
100+
]
101+
assert all("pattern" not in step["with"] for step in downloads)
102+
assert all(step["with"]["path"] == "${{ runner.temp }}/digests" for step in downloads)
103+
104+
71105
def test_release_workflow_publishes_both_node_packages_to_github_packages() -> None:
72106
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
73107

@@ -1287,6 +1321,22 @@ def test_release_please_config_and_manifest_are_present_and_consistent() -> None
12871321
"changelog because the bot can't find its baseline."
12881322
)
12891323

1324+
# This manifest has one package. Sending it through the merge plugin
1325+
# produces the group title `chore: release main`, which contains neither
1326+
# the package component nor its version. On merge, release-please cannot
1327+
# associate that title with `headroom-ai`, leaves the PR tagged
1328+
# `autorelease: pending`, and never emits the release event that publishes
1329+
# to PyPI. Keep the single package on the normal, versioned PR path and
1330+
# preserve the component in the title used to match the merged PR.
1331+
assert config.get("separate-pull-requests") is True, (
1332+
"The single root package must bypass release-please's merge plugin; "
1333+
"its grouped PR title is `chore: release main` and cannot be tagged."
1334+
)
1335+
assert config.get("pull-request-title-pattern") == ("chore: release${component} ${version}"), (
1336+
"Release PR titles must include both component and version so "
1337+
"release-please can match the merged PR back to headroom-ai."
1338+
)
1339+
12901340
# extra-files: TypeScript SDK and npm plugin package.json files
12911341
# files must be in lockstep with pyproject.toml.
12921342
extra_paths = {ef["path"] for ef in root_pkg.get("extra-files", [])}

0 commit comments

Comments
 (0)