From b430a8a78f850c2fb579272c523a2d87d3b3c68f Mon Sep 17 00:00:00 2001 From: Kristian Vastveit Date: Wed, 22 Apr 2026 11:56:25 +0200 Subject: [PATCH] feat(v1.1.0): PascalCase mcp_ tools + Claude Code 2.1.112 fingerprint Anthropic tightened OAuth validation between 2026-04-14 and 2026-04-22. Requests are now classified as third-party and routed to extra-usage credits (or rejected with 'Third-party apps now draw from your extra usage, not your plan limits') unless the fingerprint matches real Claude Code. Ports the two upstream opencode-claude-auth fixes that address this: - PR #191 (merged, v1.4.10): PascalCase `mcp_` tool names. Hermes prefixes tools with lowercase `mcp_bash`; the validator now flags that as non-Claude-Code. Real CC uses `mcp_Bash` / `mcp_Read` / `mcp_Background_output`. Adds a request-side rewrite hook plus a response-side unhook that lowercases the first char after hermes strips the prefix, so the tool dispatcher continues to receive the original lowercase names. - PR #207 (open, attempted fix for Claude Code 2.1.112): updates the billing fingerprint. Changes the `cc_entrypoint` in the signed billing header from `cli` to `sdk-cli`, adds the `advisor-tool-2026-03-01` beta flag, injects the Stainless SDK headers that real CC sends (`x-stainless-*`) plus `anthropic-dangerous-direct-browser-access: true`, and appends `?beta=true` to the /v1/messages query string. All non-body signals ride the Anthropic Python SDK's per-request `extra_headers` and `extra_query` kwargs so we don't need to rewrap the HTTP client. Other changes: - Bump __version__ to 1.1.0 and document the rationale above in the module docstring. - Extend `_EXTRA_OAUTH_BETAS` so both prompt-caching and advisor-tool betas are appended to hermes's `_OAUTH_ONLY_BETAS` list. - 10 new unit tests covering PascalCase rewrite (request + response), sdk-cli entrypoint, Stainless headers, extra_query=beta:true, and preservation of pre-existing extra_headers. Smoke-tested on Linux with hermes-agent v0.10.0 against claude-sonnet-4-6, claude-opus-4-6, and claude-opus-4-7 \u2014 all return 200 and complete responses with the new patch loaded. Billing-tier routing (plan vs extra-usage) cannot be verified from the client side; this tracks the best-known upstream bypass as of 2026-04-22 but Anthropic may tighten validation again at any time. Refs: #6 Upstream: https://github.com/griffinmartin/opencode-claude-auth/pull/191 Upstream: https://github.com/griffinmartin/opencode-claude-auth/pull/207 --- anthropic_billing_bypass.py | 267 ++++++++++++++++++++++++++++++++++-- tests/test_bypass.py | 144 +++++++++++++++++++ 2 files changed, 398 insertions(+), 13 deletions(-) diff --git a/anthropic_billing_bypass.py b/anthropic_billing_bypass.py index 21778f4..345921c 100644 --- a/anthropic_billing_bypass.py +++ b/anthropic_billing_bypass.py @@ -3,8 +3,9 @@ Claude Code OAuth bypass for hermes-agent. ========================================== Monkey-patches hermes-agent's ``agent.anthropic_adapter.build_anthropic_kwargs`` -at import time via a sitecustomize.py hook so that OAuth-authenticated requests -pass Anthropic's server-side content validation. +and ``normalize_anthropic_response`` at import time via a sitecustomize.py hook +so that OAuth-authenticated requests pass Anthropic's server-side content +validation and still route to the Claude Max/Pro subscription tier. Background ---------- @@ -23,10 +24,37 @@ opencode-claude-auth v1.4.8 (PR #148) worked around it by: message wrapped in ```` blocks. 3. Adding the ``prompt-caching-scope-2026-01-05`` beta flag. +Between 2026-04-14 and 2026-04-16, Anthropic tightened the validator further. +Two additional signals matter: + + - Tool names are now inspected: real Claude Code uses PascalCase after the + ``mcp_`` prefix (``mcp_Bash``, ``mcp_Read``, ``mcp_Background_output``). + Requests with lowercase names (``mcp_bash``) are classified as third-party + and the response says "Third-party apps now draw from your extra usage, + not your plan limits." This was fixed in opencode-claude-auth PR #191. + - The request fingerprint was updated in Claude Code 2.1.112 (upstream PR + #207, currently unmerged): the billing entrypoint changed from ``cli`` to + ``sdk-cli``, the ``advisor-tool-2026-03-01`` beta flag was added, the SDK + now sends ``x-stainless-*`` headers and ``anthropic-dangerous-direct- + browser-access: true``, and ``/v1/messages`` is called with ``?beta=true``. + hermes-agent already implements the Claude Code identity prefix, user-agent -spoofing, ``x-app: cli``, tool name ``mcp_`` prefixing, and the -``oauth-2025-04-20`` / ``claude-code-20250219`` beta flags. This patch adds -only the three items above plus a temperature fix for Opus 4.6 adaptive thinking. +spoofing, ``x-app: cli``, lowercase tool name ``mcp_`` prefixing, Hermes→Claude +Code product-name scrubbing, dynamic Claude CLI version detection, and the +``oauth-2025-04-20`` / ``claude-code-20250219`` beta flags. + +This patch fills the remaining gaps: + + - Signed billing header (system[0]) with the ``sdk-cli`` entrypoint. + - System prompt relocation to first user message. + - ``prompt-caching-scope-2026-01-05`` + ``advisor-tool-2026-03-01`` beta flags. + - PascalCase rewrite of hermes's lowercase ``mcp_`` prefixed tool names in + both the outgoing request and the response normalization path (so the tool + dispatcher continues to receive the original lowercase names). + - Stainless SDK spoof headers + ``anthropic-dangerous-direct-browser-access`` + + ``?beta=true`` query param injected via the Anthropic SDK's per-request + ``extra_headers`` / ``extra_query`` kwargs. + - Temperature fix for Opus 4.6 adaptive thinking (HTTP 400 otherwise). Installation ------------ @@ -45,21 +73,30 @@ site-packages and restart hermes-gateway. References ---------- - https://github.com/griffinmartin/opencode-claude-auth -- https://github.com/griffinmartin/opencode-claude-auth/pull/148 +- https://github.com/griffinmartin/opencode-claude-auth/pull/148 (billing header) +- https://github.com/griffinmartin/opencode-claude-auth/pull/191 (PascalCase tools) +- https://github.com/griffinmartin/opencode-claude-auth/pull/207 (Claude Code 2.1.112 fingerprint) Version history --------------- - 1.0.0 (2026-04-09): Initial — billing header, system prompt relocation, prompt-caching beta flag, aux-client temperature hook for Opus 4.6. +- 1.1.0 (2026-04-22): PascalCase ``mcp_`` tool prefix (request + response), + ``sdk-cli`` billing entrypoint, ``advisor-tool-2026-03-01`` beta flag, + Stainless SDK spoof headers, ``anthropic-dangerous-direct-browser-access`` + header, ``?beta=true`` query param on ``/v1/messages``. Addresses the + "Third-party apps now draw from your extra usage, not your plan limits" + 400 error introduced by Anthropic's 2026-04-14+ validator tightening. """ from __future__ import annotations -__version__ = "1.0.0" +__version__ = "1.1.0" import hashlib import inspect import logging +import platform import sys import traceback from typing import Any, Dict, List @@ -74,11 +111,36 @@ logger = logging.getLogger("anthropic_billing_bypass") # uses this salt to verify billing-header signatures. _BILLING_SALT = "59cf53e54c78" +# Billing entrypoint — Claude Code 2.1.112+ reports ``sdk-cli`` instead of the +# legacy ``cli`` value. Anthropic's validator matches this against the +# x-stainless-* headers; a mismatch routes the request to third-party billing. +_BILLING_ENTRYPOINT = "sdk-cli" + # Sentinel strings — entries in system[] starting with these are kept; # everything else is relocated to the first user message. _BILLING_PREFIX = "x-anthropic-billing-header" _SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude." +# Tool-name prefix used by hermes-agent's existing OAuth path. We rewrite +# hermes's lowercase ``mcp_foo`` to Claude Code's PascalCase ``mcp_Foo``. +_MCP_PREFIX = "mcp_" + +# Stainless SDK version the Anthropic JS SDK reports. Real Claude Code ships +# @anthropic-ai/sdk@0.81.0 as of 2.1.112 — we spoof the same value. +_STAINLESS_PACKAGE_VERSION = "0.81.0" + +# Node runtime version Claude Code 2.1.112 runs under. We send a recent LTS +# value rather than our actual Python version (which would give us away). +_STAINLESS_NODE_VERSION = "v22.11.0" + +# Additional beta flags the OAuth path needs on top of hermes-agent's built-in +# ``claude-code-20250219`` and ``oauth-2025-04-20``. These are appended to +# ``_OAUTH_ONLY_BETAS`` in ``apply_patches``. +_EXTRA_OAUTH_BETAS = [ + "prompt-caching-scope-2026-01-05", + "advisor-tool-2026-03-01", +] + def _extract_first_user_message_text(messages: List[Dict[str, Any]]) -> str: """Return the text of the first user message's first text block. @@ -137,6 +199,114 @@ def _build_billing_header_value( ) +def _stainless_arch() -> str: + machine = (platform.machine() or "").lower() + if machine in ("x86_64", "amd64"): + return "x64" + if machine in ("arm64", "aarch64"): + return "arm64" + if machine in ("i386", "i686"): + return "ia32" + return machine or "unknown" + + +def _stainless_os() -> str: + mapping = {"Darwin": "MacOS", "Linux": "Linux", "Windows": "Windows"} + return mapping.get(platform.system(), platform.system() or "Unknown") + + +def _build_spoof_headers() -> Dict[str, str]: + """Headers real Claude Code 2.1.112 sends that hermes-agent does not. + + The Anthropic SDK (Stainless-generated) automatically attaches + ``x-stainless-*`` identifying headers. The validator cross-references these + with the billing header's ``cc_entrypoint``; absent or mismatched values + flag the request as third-party. ``anthropic-dangerous-direct-browser- + access: true`` is a separate Claude Code CLI behavior. + """ + return { + "anthropic-dangerous-direct-browser-access": "true", + "x-stainless-arch": _stainless_arch(), + "x-stainless-lang": "js", + "x-stainless-os": _stainless_os(), + "x-stainless-package-version": _STAINLESS_PACKAGE_VERSION, + "x-stainless-retry-count": "0", + "x-stainless-runtime": "node", + "x-stainless-runtime-version": _STAINLESS_NODE_VERSION, + "x-stainless-timeout": "600", + } + + +def _pascalcase_mcp_name(name: str) -> str: + """Rewrite ``mcp_foo_bar`` → ``mcp_Foo_bar``. + + Matches opencode-claude-auth PR #191 exactly: only the character + immediately following the ``mcp_`` prefix is uppercased. Names already in + PascalCase are returned unchanged. + """ + if not isinstance(name, str) or not name.startswith(_MCP_PREFIX): + return name + rest = name[len(_MCP_PREFIX):] + if not rest or not rest[0].islower(): + return name + return _MCP_PREFIX + rest[0].upper() + rest[1:] + + +def _rewrite_tool_names_pascalcase(api_kwargs: Dict[str, Any]) -> None: + """Convert hermes-agent's lowercase ``mcp_`` tool names to PascalCase. + + Hermes prefixes tools with ``mcp_`` at line 1365 of ``anthropic_adapter`` + using the literal ``_MCP_TOOL_PREFIX + tool["name"]``, which produces + lowercase values like ``mcp_bash``. Anthropic's billing validator + blacklists specific lowercase names (e.g. ``background_output``), flagging + the client as non-Claude-Code. Real Claude Code uses PascalCase after the + prefix; we rewrite the request in-place. + """ + tools = api_kwargs.get("tools") + if isinstance(tools, list): + for tool in tools: + if isinstance(tool, dict) and "name" in tool: + tool["name"] = _pascalcase_mcp_name(tool.get("name") or "") + + messages = api_kwargs.get("messages") + if isinstance(messages, list): + for msg in messages: + if not isinstance(msg, dict): + continue + content = msg.get("content") + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_use" and "name" in block: + block["name"] = _pascalcase_mcp_name(block.get("name") or "") + + +def _merge_spoof_extras(api_kwargs: Dict[str, Any]) -> None: + """Inject Claude Code 2.1.112 request fingerprint via extra_headers / extra_query. + + The Anthropic Python SDK forwards both to the underlying HTTP request: + ``extra_headers`` becomes request headers (merged with client defaults), + ``extra_query`` becomes URL query parameters. We avoid overwriting values + already set by hermes-agent (e.g. its fast-mode ``anthropic-beta`` header) + so our spoof is additive. + """ + existing_headers = api_kwargs.get("extra_headers") + merged_headers: Dict[str, str] = dict(_build_spoof_headers()) + if isinstance(existing_headers, dict): + for key, value in existing_headers.items(): + merged_headers[key] = value + api_kwargs["extra_headers"] = merged_headers + + existing_query = api_kwargs.get("extra_query") + merged_query: Dict[str, str] = {"beta": "true"} + if isinstance(existing_query, dict): + for key, value in existing_query.items(): + merged_query[key] = value + api_kwargs["extra_query"] = merged_query + + # --------------------------------------------------------------------------- # Bypass logic (ported from opencode-claude-auth/src/transforms.ts) # --------------------------------------------------------------------------- @@ -243,7 +413,9 @@ def apply_claude_code_bypass(api_kwargs: Dict[str, Any], version: str) -> None: # Compute billing header using ORIGINAL messages (before relocation). try: - billing_value = _build_billing_header_value(messages, version, "cli") + billing_value = _build_billing_header_value( + messages, version, _BILLING_ENTRYPOINT + ) except Exception as exc: logger.warning("Failed to build billing header: %s", exc) return @@ -287,6 +459,8 @@ def apply_claude_code_bypass(api_kwargs: Dict[str, Any], version: str) -> None: if moved_texts: _prepend_to_first_user_message(messages, moved_texts) + _rewrite_tool_names_pascalcase(api_kwargs) + _merge_spoof_extras(api_kwargs) _fix_temperature_for_oauth_adaptive(api_kwargs, site="build_kwargs") @@ -311,6 +485,71 @@ def _get_version_safely(aa_module: Any) -> str: return "2.1.90" +def _lowercase_first(name: str) -> str: + if not name: + return name + return name[0].lower() + name[1:] + + +def _install_response_pascalcase_unhook(aa_module: Any, force: bool = False) -> bool: + """Post-process ``normalize_anthropic_response`` to restore lowercase tool names. + + We rewrote outgoing tool names from ``mcp_bash`` to ``mcp_Bash`` to pass + Anthropic's validator. The response comes back referencing ``mcp_Bash`` + too. Hermes strips the ``mcp_`` prefix (line 1488-1489 of + ``anthropic_adapter``), leaving ``Bash`` — which hermes's tool dispatcher + cannot find because the registered name is ``bash``. We wrap + ``normalize_anthropic_response`` to lowercase the first character of each + tool call name after hermes's strip runs. + """ + if getattr(aa_module, "_CLAUDE_CODE_RESPONSE_UNHOOK_APPLIED", False) and not force: + logger.debug("response PascalCase unhook already installed") + return True + + original = getattr(aa_module, "normalize_anthropic_response", None) + if not callable(original): + logger.warning("normalize_anthropic_response not found; skipping response unhook") + return False + + def patched_normalize(response: Any, strip_tool_prefix: bool = False, **kwargs: Any) -> Any: + result = original(response, strip_tool_prefix=strip_tool_prefix, **kwargs) + if not strip_tool_prefix: + return result + try: + assistant_message, _finish = result + except (TypeError, ValueError): + return result + tool_calls = getattr(assistant_message, "tool_calls", None) + if not tool_calls: + return result + for tc in tool_calls: + fn = getattr(tc, "function", None) + if fn is None: + continue + name = getattr(fn, "name", None) + if isinstance(name, str) and name and name[0].isupper(): + try: + fn.name = _lowercase_first(name) + except Exception: + pass + return result + + patched_normalize.__name__ = original.__name__ + patched_normalize.__qualname__ = getattr( + original, "__qualname__", original.__name__ + ) + patched_normalize.__doc__ = original.__doc__ + patched_normalize.__wrapped__ = original # type: ignore[attr-defined] + + aa_module.normalize_anthropic_response = patched_normalize + aa_module._CLAUDE_CODE_RESPONSE_UNHOOK_APPLIED = True # type: ignore[attr-defined] + logger.info("Response PascalCase unhook installed on normalize_anthropic_response") + sys.stderr.write( + "[anthropic_billing_bypass] Response PascalCase unhook installed\n" + ) + return True + + def _install_aux_client_hook(force: bool = False) -> bool: """Patch the auxiliary client to strip temperature on OAuth adaptive models.""" try: @@ -426,12 +665,13 @@ def apply_patches(anthropic_adapter_module: Any = None) -> bool: logger.debug("Claude Code bypass already installed") return True - # 1. Add the missing beta flag. - new_beta = "prompt-caching-scope-2026-01-05" + # 1. Add the missing beta flags (prompt-caching + advisor-tool). oauth_betas = getattr(aa, "_OAUTH_ONLY_BETAS", None) - if isinstance(oauth_betas, list) and new_beta not in oauth_betas: - oauth_betas.append(new_beta) - logger.info("Appended beta flag: %s", new_beta) + if isinstance(oauth_betas, list): + for new_beta in _EXTRA_OAUTH_BETAS: + if new_beta not in oauth_betas: + oauth_betas.append(new_beta) + logger.info("Appended beta flag: %s", new_beta) # 2. Verify the target function exists with the expected signature. original_build = getattr(aa, "build_anthropic_kwargs", None) @@ -492,6 +732,7 @@ def apply_patches(anthropic_adapter_module: Any = None) -> bool: logger.info("Claude Code OAuth bypass installed (build_anthropic_kwargs)") sys.stderr.write("[anthropic_billing_bypass] Claude Code OAuth bypass installed\n") + _install_response_pascalcase_unhook(aa) _install_aux_client_hook() return True diff --git a/tests/test_bypass.py b/tests/test_bypass.py index ff5d936..25419b1 100644 --- a/tests/test_bypass.py +++ b/tests/test_bypass.py @@ -1,10 +1,14 @@ # pyright: reportPrivateUsage=false, reportUnknownParameterType=false, reportMissingParameterType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportArgumentType=false import copy +from types import SimpleNamespace from anthropic_billing_bypass import ( + _BILLING_ENTRYPOINT, _SYSTEM_IDENTITY, _fix_temperature_for_oauth_adaptive, + _install_response_pascalcase_unhook, + _pascalcase_mcp_name, apply_claude_code_bypass, ) @@ -19,6 +23,14 @@ def test_apply_claude_code_bypass_injects_billing_header_and_preserves_identity( assert system[1]["text"] == _SYSTEM_IDENTITY +def test_apply_claude_code_bypass_uses_sdk_cli_entrypoint(basic_api_kwargs): + apply_claude_code_bypass(basic_api_kwargs, "2.1.112") + + billing_text = basic_api_kwargs["system"][0]["text"] + assert "cc_entrypoint=sdk-cli;" in billing_text + assert _BILLING_ENTRYPOINT == "sdk-cli" + + def test_apply_claude_code_bypass_relocates_non_identity_system_text_to_first_user_message( basic_api_kwargs, ): @@ -93,3 +105,135 @@ def test_fix_temperature_for_oauth_adaptive_without_temperature_is_noop(): api_kwargs = {"model": "claude-opus-4-6-20260101"} _fix_temperature_for_oauth_adaptive(api_kwargs, site="test") assert api_kwargs == {"model": "claude-opus-4-6-20260101"} + + +def test_pascalcase_mcp_name_uppercases_first_char_after_prefix(): + assert _pascalcase_mcp_name("mcp_bash") == "mcp_Bash" + assert _pascalcase_mcp_name("mcp_read") == "mcp_Read" + assert _pascalcase_mcp_name("mcp_background_output") == "mcp_Background_output" + + +def test_pascalcase_mcp_name_leaves_already_pascalcase_unchanged(): + assert _pascalcase_mcp_name("mcp_Bash") == "mcp_Bash" + assert _pascalcase_mcp_name("mcp_Background_output") == "mcp_Background_output" + + +def test_pascalcase_mcp_name_ignores_unprefixed_names(): + assert _pascalcase_mcp_name("bash") == "bash" + assert _pascalcase_mcp_name("not_mcp_bash") == "not_mcp_bash" + assert _pascalcase_mcp_name("") == "" + + +def test_apply_claude_code_bypass_rewrites_tool_names_to_pascalcase(basic_api_kwargs): + basic_api_kwargs["tools"] = [ + {"name": "mcp_bash"}, + {"name": "mcp_background_output"}, + {"name": "mcp_Already_pascal"}, + ] + basic_api_kwargs["messages"] = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello world"}, + {"type": "tool_use", "name": "mcp_bash", "id": "tool_1", "input": {}}, + ], + } + ] + + apply_claude_code_bypass(basic_api_kwargs, "2.1.112") + + tool_names = [tool["name"] for tool in basic_api_kwargs["tools"]] + assert tool_names == ["mcp_Bash", "mcp_Background_output", "mcp_Already_pascal"] + + tool_use_block = basic_api_kwargs["messages"][0]["content"][-1] + assert tool_use_block["name"] == "mcp_Bash" + + +def test_apply_claude_code_bypass_injects_stainless_and_direct_browser_headers( + basic_api_kwargs, +): + apply_claude_code_bypass(basic_api_kwargs, "2.1.112") + + extra_headers = basic_api_kwargs["extra_headers"] + assert extra_headers["anthropic-dangerous-direct-browser-access"] == "true" + assert extra_headers["x-stainless-lang"] == "js" + assert extra_headers["x-stainless-runtime"] == "node" + assert extra_headers["x-stainless-package-version"] == "0.81.0" + assert extra_headers["x-stainless-retry-count"] == "0" + assert extra_headers["x-stainless-timeout"] == "600" + assert extra_headers["x-stainless-os"] in ("MacOS", "Linux", "Windows") + assert extra_headers["x-stainless-arch"] in ("x64", "arm64", "ia32", "unknown") + + +def test_apply_claude_code_bypass_sets_beta_true_query_param(basic_api_kwargs): + apply_claude_code_bypass(basic_api_kwargs, "2.1.112") + + assert basic_api_kwargs["extra_query"] == {"beta": "true"} + + +def test_apply_claude_code_bypass_preserves_existing_extra_headers(basic_api_kwargs): + basic_api_kwargs["extra_headers"] = {"anthropic-beta": "fast-mode-2026-02-01"} + + apply_claude_code_bypass(basic_api_kwargs, "2.1.112") + + assert basic_api_kwargs["extra_headers"]["anthropic-beta"] == "fast-mode-2026-02-01" + assert ( + basic_api_kwargs["extra_headers"]["anthropic-dangerous-direct-browser-access"] + == "true" + ) + + +def _make_fake_adapter_module(tool_names): + def original_normalize(response, strip_tool_prefix=False): + tool_calls = [] + for name in tool_names: + stripped = name[len("mcp_"):] if strip_tool_prefix and name.startswith("mcp_") else name + tool_calls.append( + SimpleNamespace( + id="tool_1", + type="function", + function=SimpleNamespace(name=stripped, arguments="{}"), + ) + ) + msg = SimpleNamespace(content=None, tool_calls=tool_calls or None, reasoning=None) + return msg, "tool_calls" + + module = SimpleNamespace(normalize_anthropic_response=original_normalize) + return module + + +def test_response_unhook_lowercases_first_char_of_tool_names_after_strip(): + adapter = _make_fake_adapter_module(["mcp_Bash", "mcp_Background_output"]) + + assert _install_response_pascalcase_unhook(adapter) is True + + msg, _reason = adapter.normalize_anthropic_response( + response=object(), strip_tool_prefix=True + ) + + names = [tc.function.name for tc in msg.tool_calls] + assert names == ["bash", "background_output"] + + +def test_response_unhook_is_noop_when_strip_tool_prefix_false(): + adapter = _make_fake_adapter_module(["mcp_Bash"]) + + _install_response_pascalcase_unhook(adapter) + + msg, _reason = adapter.normalize_anthropic_response( + response=object(), strip_tool_prefix=False + ) + + assert msg.tool_calls[0].function.name == "mcp_Bash" + + +def test_response_unhook_is_idempotent(): + adapter = _make_fake_adapter_module(["mcp_Bash"]) + + assert _install_response_pascalcase_unhook(adapter) is True + assert _install_response_pascalcase_unhook(adapter) is True + + msg, _reason = adapter.normalize_anthropic_response( + response=object(), strip_tool_prefix=True + ) + assert msg.tool_calls[0].function.name == "bash"