Skip to content

fix(anthropic): canonicalize system prompts with the shared stripper - #767

Merged
waybarrios merged 2 commits into
waybarrios:mainfrom
janhilgard:fix/canonicalize-anthropic-adapter
Sep 5, 2026
Merged

waybarrios merged 2 commits into
waybarrios:mainfrom
janhilgard:fix/canonicalize-anthropic-adapter

Conversation

@janhilgard

Copy link
Copy Markdown
Collaborator

Follow-up to #524, requested in that thread. Replaces the Anthropic adapter's inline billing-header regex with the shared canonicalize_system_prompt() from #528.

The defect

anthropic_to_openai() carried its own pattern:

system_text = re.sub(r"x-anthropic-billing-header:[^\n]*\n?", "", system_text)

It is unanchored, so it matches anywhere on a line — including the middle of a user's own sentence. And it runs before _prepare_chat_messages() calls canonicalize_system_messages(), so the shared pass cannot restore what it already deleted:

in:                    "Explain what x-anthropic-billing-header: means in HTTP terms."
after the adapter:     "Explain what "
after the shared pass: "Explain what "
without the adapter:   "Explain what x-anthropic-billing-header: means in HTTP terms."

The two also disagreed on case, in the opposite direction: the shared stripper is (?im)-anchored and removes a capitalised standalone header; the inline one did not.

input inline only inline → shared shared only
x-anthropic-billing-header: … on its own line removed removed removed
X-Anthropic-Billing-Header: … on its own line kept removed removed
header text mid-sentence removed removed kept

Only the second row was a cache-hit question, and on current main the shared pass already covers it, so that half is not a live defect. The third row is user-visible data loss, and it is the reason for this change.

The fix

system_text = canonicalize_system_prompt(system_text) or ""

One definition of the pattern instead of two, which is what the registry in #528 was for: the Anthropic path now picks up any future stripper without a second edit. The re import is no longer needed in the adapter and is dropped.

Tests

tests/test_anthropic_adapter_canonicalization.py, 15 cases across four groups:

  • standalone header removed — lower, mixed and upper case; header as the entire prompt; trailing header with no newline
  • user text preserved — the mid-line mention, asserted both after the adapter alone and after the second pass
  • unrelated content untouched — plain prompts, ISO timestamps, an unrelated Authorization: header, prose mentioning billing headers
  • both stages agree — the shared pass is a no-op on whatever the adapter returns, so the two can no longer drift apart silently

Every case drives the real anthropic_to_openai() and, where the two-stage behaviour matters, the real canonicalize_system_messages() — no reimplementation of either.

Mutation-checked. Restoring the inline regex fails 5 of the 15:

FAILED …::test_case_insensitive_on_its_own_line[mixed]
FAILED …::test_case_insensitive_on_its_own_line[upper]
FAILED …::test_mid_line_mention_survives
FAILED …::test_mid_line_mention_survives_the_second_pass_too
FAILED …::test_second_pass_is_a_no_op[mixed]

Verification

tests/test_anthropic_adapter_canonicalization.py     15 passed
anthropic / canonical / prefix / server suites      554 passed, 3 skipped

black clean. No benchmark here: this is a correctness fix on a code path, and the cache-hit claim I originally made for it did not survive checking.

The adapter carried its own unanchored `re.sub` for the billing header.
It runs before `canonicalize_system_messages()`, so whatever it removed
was already gone when the shared, line-anchored stripper saw the
messages -- and that pass cannot restore it.

Unanchored means it matched anywhere, including inside a user's own
sentence:

    in:  "Explain what x-anthropic-billing-header: means in HTTP terms."
    out: "Explain what "

It was also case-sensitive where the shared stripper is not, so the two
stages disagreed in both directions on the same input.

Replacing it with `canonicalize_system_prompt()` leaves one definition of
the pattern, so the Anthropic path picks up any future stripper for free
-- the point of the registry added in waybarrios#528.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@janhilgard
janhilgard force-pushed the fix/canonicalize-anthropic-adapter branch from 1c36039 to 77ae906 Compare September 4, 2026 14:52

@Thump604 Thump604 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The source change at 77ae9065 matches the narrow fix agreed in #524: reuse the shared line-anchored canonicalizer instead of deleting mid-sentence prompt text. I checked the real adapter, shared helper and server preparation call path; I found no further source defect in this change. The new tests exercise the real adapter and shared helper, without adding speculative strippers or a benchmark.

One CI wiring item before landing: tests/test_anthropic_adapter_canonicalization.py is absent from the workflow's explicit test lists. I checked run 33886240337, including the Linux 3.12 and Apple 3.11 logs: the existing adapter tests execute, but these 15 new cases do not. The green checks therefore do not cover them yet.

Please add that filename beside tests/test_anthropic_adapter.py in the existing no-MLX unit-test invocation. That is the only remaining request from me; no additional behavior, framework or model benchmark.

@waybarrios

Copy link
Copy Markdown
Owner

@janhilgard just added a missing test into the CI settings. Now it is ready to merge.

@janhilgard

Copy link
Copy Markdown
Collaborator Author

Thanks both — and thanks @waybarrios for wiring it in directly rather than bouncing it back.

I verified the wiring executes rather than assuming the green checks meant it, since that is precisely what the checks did not mean before 505c9a19. From the run-33890662441 job logs, all 15 cases run in every matrix job:

test-matrix (3.10): 15
test-matrix (3.11): 15
test-matrix (3.12): 15
test-matrix (3.13): 15

with individual ids present, e.g. test_case_insensitive_on_its_own_line[lower|mixed|upper], test_mid_line_mention_survives, test_second_pass_is_a_no_op[mixed] — including the five that fail when the inline regex is restored, so the coverage is live and not merely collected. Job total 1408 passed, 19 skipped.

@Thump604 — you caught the same gap on #690, and both times it was a new test file that CI never saw. That is a repeatable footgun rather than two coincidences: a green PR proves nothing about a file nobody listed. I have started running the exact CI invocation locally before pushing rather than only black, which is also how the F541 here would have been caught before it reached the runner instead of after.

Nothing further from me on this one.

@Thump604 Thump604 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 505c9a19. The source remains the narrow shared-canonicalizer fix, and the follow-up commit adds the new test file to the existing Linux matrix invocation. I independently checked run 33890662441: all 15 canonicalization cases execute and pass on Python 3.10, 3.11, 3.12, and 3.13; all ten checks are green.

The prior CI-wiring request is resolved. I found no remaining blocker in this PR.

@waybarrios
waybarrios merged commit ec8e493 into waybarrios:main Sep 5, 2026
10 checks passed
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.

3 participants