From fdb35906934c614192dce6a298ea64617d53b44f Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 07:05:05 +0000 Subject: [PATCH 01/34] chore(copilot): add SDK CLI override + OpenRouter compat regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We've been pinned at `claude-agent-sdk==0.1.45` (bundled CLI 2.1.63) since PR #12294 because every version above introduces a 400 against OpenRouter. There are two stacked regressions today: 1. CLI 2.1.69 (= SDK 0.1.46) added a `tool_reference` content block in `tool_result.content` that OpenRouter's stricter Zod validation rejects. CLI 2.1.70 added a proxy-detection workaround but our subsequent attempts at 0.1.55 and 0.1.56 still failed. 2. A newer regression — the `context-management-2025-06-27` beta header — appears in some CLI version after 2.1.91. Tracked upstream at anthropics/claude-agent-sdk-python#789, still open with no fix. This commit doesn't actually upgrade the SDK — it adds the infrastructure we need to upgrade safely *when* upstream lands a fix or when we identify a known-good newer CLI version via bisection: * `ChatConfig.claude_agent_cli_path` (env: `CLAUDE_AGENT_CLI_PATH`) threads through to `ClaudeAgentOptions(cli_path=...)` so we can decouple the Python SDK API surface from the CLI binary version. `_prewarm_cli` in the CoPilotExecutor honours the same override. * `test_bundled_cli_version_is_known_good_against_openrouter` pins the bundled CLI to a known-good set (`{"2.1.63"}` today). Any `claude-agent-sdk` bump that changes the bundled CLI will fail this test loudly with a pointer to PR #12294 and issue #789, instead of silently re-breaking production. * `test_sdk_exposes_cli_path_option` is a forward-compat sentinel that fails fast if upstream removes the `cli_path` option we depend on for the override. * `cli_openrouter_compat_test.py` is the actual reproduction test: spawns the bundled (or `CLAUDE_AGENT_CLI_PATH`-overridden) CLI against an in-process aiohttp server pretending to be the Anthropic Messages API, captures every request body the CLI sends, and asserts that none of them contain the two known forbidden patterns (`"type": "tool_reference"` content blocks or `"context-management-2025-06-27"` in body or `anthropic-beta` header). The fake server returns a minimal valid streamed response so the CLI doesn't error out before we can inspect what it sent. No OpenRouter API key required — the test reproduces the *mechanism* rather than the symptom, so it's deterministic and free to run in CI. Workflow for verifying a candidate upgrade going forward: bump the SDK in `pyproject.toml`, push the commit, and watch the CI run for both tests in `sdk_compat_test.py` and `cli_openrouter_compat_test.py`. A clean run on both means it's safe to add the new bundled CLI version to `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS` and merge. --- .../backend/backend/copilot/config.py | 12 + .../backend/copilot/executor/processor.py | 24 +- .../copilot/sdk/cli_openrouter_compat_test.py | 424 ++++++++++++++++++ .../backend/copilot/sdk/sdk_compat_test.py | 76 ++++ .../backend/backend/copilot/sdk/service.py | 6 + 5 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 6da1cae52b..7cbe268f34 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -172,6 +172,18 @@ class ChatConfig(BaseSettings): description="Maximum number of retries for transient API errors " "(429, 5xx, ECONNRESET) before surfacing the error to the user.", ) + claude_agent_cli_path: str | None = Field( + default=None, + description="Optional explicit path to a Claude Code CLI binary. " + "When set, the SDK uses this binary instead of the version bundled " + "with the installed `claude-agent-sdk` package — letting us pin " + "the Python SDK and the CLI independently. Critical for keeping " + "OpenRouter compatibility while still picking up newer SDK API " + "features (the bundled CLI version in 0.1.46+ is broken against " + "OpenRouter — see PR #12294 and " + "anthropics/claude-agent-sdk-python#789). Falls back to the " + "bundled binary when unset.", + ) use_openrouter: bool = Field( default=True, description="Enable routing API calls through the OpenRouter proxy. " diff --git a/autogpt_platform/backend/backend/copilot/executor/processor.py b/autogpt_platform/backend/backend/copilot/executor/processor.py index 15d1e65d4e..2f9e563784 100644 --- a/autogpt_platform/backend/backend/copilot/executor/processor.py +++ b/autogpt_platform/backend/backend/copilot/executor/processor.py @@ -174,13 +174,25 @@ class CoPilotProcessor: logger.info(f"[CoPilotExecutor] Worker {self.tid} started") def _prewarm_cli(self) -> None: - """Run the bundled CLI binary once to warm OS page caches.""" - try: - from claude_agent_sdk._internal.transport.subprocess_cli import ( - SubprocessCLITransport, - ) + """Run the Claude Code CLI binary once to warm OS page caches. - cli_path = SubprocessCLITransport._find_bundled_cli(None) # type: ignore[arg-type] + Honours the ``claude_agent_cli_path`` config override (which lets + us run a pinned CLI version independent of the bundled one in the + installed ``claude-agent-sdk`` wheel — see + ``ChatConfig.claude_agent_cli_path`` for the rationale). Falls + back to the bundled binary when no override is set. + """ + try: + from backend.copilot.config import ChatConfig + + cfg = ChatConfig() + cli_path: str | None = cfg.claude_agent_cli_path + if not cli_path: + from claude_agent_sdk._internal.transport.subprocess_cli import ( + SubprocessCLITransport, + ) + + cli_path = SubprocessCLITransport._find_bundled_cli(None) # type: ignore[arg-type] if cli_path: result = subprocess.run( [cli_path, "-v"], diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py new file mode 100644 index 0000000000..b55e6b9a66 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -0,0 +1,424 @@ +"""Reproduction test for the OpenRouter incompatibility in newer +``claude-agent-sdk`` / Claude Code CLI versions. + +Background — there are two stacked regressions that block us from +upgrading the ``claude-agent-sdk`` package above ``0.1.45``: + +1. **`tool_reference` content blocks** introduced by CLI ``2.1.69`` (= + SDK ``0.1.46``). The CLI's built-in ``ToolSearch`` tool returns + ``{"type": "tool_reference", "tool_name": "..."}`` content blocks in + ``tool_result.content``. OpenRouter's stricter Zod validation + rejects this with:: + + messages[N].content[0].content: Invalid input: expected string, received array + + This is the regression that originally pinned us at 0.1.45 — see + https://github.com/Significant-Gravitas/AutoGPT/pull/12294 for the + full forensic write-up. CLI 2.1.70 added proxy detection that + *should* disable the offending blocks when ``ANTHROPIC_BASE_URL`` is + set, but our subsequent attempts at 0.1.55 / 0.1.56 still failed. + +2. **`context-management-2025-06-27` beta header** — some CLI version + after ``2.1.91`` started injecting this header / beta flag, which + OpenRouter rejects with:: + + 400 No endpoints available that support Anthropic's context + management features (context-management-2025-06-27). Context + management requires a supported provider (Anthropic). + + Tracked upstream at + https://github.com/anthropics/claude-agent-sdk-python/issues/789. + Still open at the time of writing, no upstream PR linked, no + workaround documented. + +The purpose of this test: +* Spin up a tiny in-process HTTP server that pretends to be the + Anthropic Messages API. +* Capture every request body the CLI sends. +* Inspect the captured bodies for the two forbidden patterns above. +* Fail loudly if either is present, with a pointer to the issue + tracker. + +This is the reproduction we use as a CI gate when bisecting which SDK / +CLI version is safe to upgrade to. It runs against the bundled CLI by +default (or against ``ChatConfig.claude_agent_cli_path`` when set), so +it doubles as a regression guard for the ``cli_path`` override +mechanism. + +The test does **not** need an OpenRouter API key — it reproduces the +mechanism (forbidden content blocks / headers in the *outgoing* +request) rather than the symptom (the 400 OpenRouter would return). +This keeps it deterministic, free, and CI-runnable without secrets. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from aiohttp import web + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Forbidden patterns we scan for in captured request bodies +# --------------------------------------------------------------------------- + +# Substring of the `tool_reference` content block that breaks OpenRouter's +# stricter Zod validation in tool_result.content. PR #12294 root-cause. +_FORBIDDEN_TOOL_REFERENCE = '"type": "tool_reference"' + +# Beta string OpenRouter rejects in upstream issue #789. Can appear in +# either `betas` arrays or the `anthropic-beta` header value. +_FORBIDDEN_CONTEXT_MANAGEMENT_BETA = "context-management-2025-06-27" + + +def _scan_request_for_forbidden_patterns( + body_text: str, + headers: dict[str, str], +) -> list[str]: + """Return a list of forbidden patterns found in *body_text* / *headers*. + + Empty list = clean request. Non-empty = the CLI is sending one of the + OpenRouter-incompatible features. + """ + findings: list[str] = [] + if _FORBIDDEN_TOOL_REFERENCE in body_text: + findings.append( + "`tool_reference` content block in request body — " + "PR #12294 / CLI 2.1.69 regression" + ) + if _FORBIDDEN_CONTEXT_MANAGEMENT_BETA in body_text: + findings.append( + f"{_FORBIDDEN_CONTEXT_MANAGEMENT_BETA!r} in request body — " + "anthropics/claude-agent-sdk-python#789" + ) + # Header values are case-insensitive in HTTP — aiohttp normalises + # incoming names but values are stored as-is. + for header_name, header_value in headers.items(): + if header_name.lower() == "anthropic-beta": + if _FORBIDDEN_CONTEXT_MANAGEMENT_BETA in header_value: + findings.append( + f"{_FORBIDDEN_CONTEXT_MANAGEMENT_BETA!r} in " + "`anthropic-beta` header — issue #789" + ) + return findings + + +# --------------------------------------------------------------------------- +# Fake Anthropic Messages API +# --------------------------------------------------------------------------- +# +# We need to give the CLI a *successful* response so it doesn't error out +# before we get a chance to inspect the request. The minimal thing the +# CLI accepts is a streamed (SSE) message-start → content-block-delta → +# message-stop sequence. +# +# We don't strictly *need* the CLI to accept the response — we already +# have the request body by the time we send any reply — but giving it a +# valid stream means the assertion failure (if any) is the *only* +# failure mode in the test, not "CLI exited 1 because we sent garbage". + + +def _build_streaming_message_response() -> str: + """Return an SSE-formatted body containing a minimal Anthropic + Messages API streamed response. + + This is the smallest stream that the Claude Code CLI will accept + end-to-end without errors. Each line is one SSE event.""" + events: list[dict[str, Any]] = [ + { + "type": "message_start", + "message": { + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-test", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "ok"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 1}, + }, + {"type": "message_stop"}, + ] + return "".join( + f"event: {evt['type']}\ndata: {json.dumps(evt)}\n\n" for evt in events + ) + + +class _CapturedRequest: + """One request the fake server received.""" + + def __init__(self, path: str, headers: dict[str, str], body: str) -> None: + self.path = path + self.headers = headers + self.body = body + + +async def _start_fake_anthropic_server( + captured: list[_CapturedRequest], +) -> tuple[web.AppRunner, int]: + """Start an aiohttp server pretending to be the Anthropic API. + + All POSTs to ``/v1/messages`` are recorded into *captured* and + answered with a valid streaming response. Returns ``(runner, port)`` + so the caller can ``await runner.cleanup()`` when finished. + """ + + async def messages_handler(request: web.Request) -> web.StreamResponse: + body = await request.text() + captured.append( + _CapturedRequest( + path=request.path, + headers={k: v for k, v in request.headers.items()}, + body=body, + ) + ) + # Stream a minimal valid response so the CLI doesn't error out + # before we can inspect what it sent. + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + await response.prepare(request) + await response.write(_build_streaming_message_response().encode("utf-8")) + await response.write_eof() + return response + + app = web.Application() + app.router.add_post("/v1/messages", messages_handler) + # OAuth/profile endpoints the CLI may probe — answer 404 so it falls + # through quickly without retrying. + app.router.add_route("*", "/{tail:.*}", lambda _r: web.Response(status=404)) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + + server = site._server + assert server is not None + sockets = getattr(server, "sockets", None) + assert sockets is not None + port: int = sockets[0].getsockname()[1] + return runner, port + + +# --------------------------------------------------------------------------- +# CLI invocation +# --------------------------------------------------------------------------- + + +def _resolve_cli_path() -> Path | None: + """Return the Claude Code CLI binary the SDK would use. + + Honours the same override mechanism as ``service.py``: explicit + ``CLAUDE_AGENT_CLI_PATH`` env var first (matching the new + ``ChatConfig.claude_agent_cli_path`` field), then the bundled + binary that ships with the installed ``claude-agent-sdk`` wheel. + """ + override = os.environ.get("CLAUDE_AGENT_CLI_PATH") + if override: + candidate = Path(override) + return candidate if candidate.is_file() else None + + try: + from claude_agent_sdk._internal.transport.subprocess_cli import ( # type: ignore[import-untyped] + SubprocessCLITransport, + ) + + bundled = SubprocessCLITransport._find_bundled_cli(None) # type: ignore[arg-type] + return Path(bundled) if bundled else None + except Exception as e: # pragma: no cover - import-time guard + logger.warning("Could not locate bundled Claude CLI: %s", e) + return None + + +async def _run_cli_against_fake_server( + cli_path: Path, + fake_server_port: int, + timeout_seconds: float, +) -> tuple[int, str, str]: + """Spawn the CLI pointed at the fake Anthropic server and feed it a + single ``user`` message via stream-json on stdin. + + Returns ``(returncode, stdout, stderr)``. The return code is not + asserted by the test — we only care that the CLI made at least one + POST to ``/v1/messages`` so the fake server captured the body. + """ + fake_url = f"http://127.0.0.1:{fake_server_port}" + env = { + # Inherit basic shell variables so the CLI can find its tools, + # but force network/auth at our fake endpoint. + **os.environ, + "ANTHROPIC_BASE_URL": fake_url, + "ANTHROPIC_API_KEY": "sk-test-fake-key-not-real", + # Disable any features that would phone home to a different host + # mid-test (telemetry, plugin marketplace fetch). + "DISABLE_TELEMETRY": "1", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + } + + # The CLI accepts stream-json input on stdin in `query` mode. A + # minimal user-message envelope is enough to trigger an API call. + stdin_payload = ( + json.dumps( + { + "type": "user", + "message": {"role": "user", "content": "hello"}, + } + ) + + "\n" + ) + + proc = await asyncio.create_subprocess_exec( + str(cli_path), + "--output-format", + "stream-json", + "--input-format", + "stream-json", + "--verbose", + "--print", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + try: + assert proc.stdin is not None + proc.stdin.write(stdin_payload.encode("utf-8")) + await proc.stdin.drain() + proc.stdin.close() + + stdout_bytes, stderr_bytes = await asyncio.wait_for( + proc.communicate(), timeout=timeout_seconds + ) + except (asyncio.TimeoutError, TimeoutError): + # Best-effort kill — we already have whatever requests the CLI + # managed to send before stalling. + try: + proc.kill() + except ProcessLookupError: + pass + stdout_bytes, stderr_bytes = b"", b"" + + return ( + proc.returncode if proc.returncode is not None else -1, + stdout_bytes.decode("utf-8", errors="replace"), + stderr_bytes.decode("utf-8", errors="replace"), + ) + + +# --------------------------------------------------------------------------- +# The actual test +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cli_does_not_send_openrouter_incompatible_features(caplog): + """End-to-end OpenRouter compatibility reproduction. + + Spawns the bundled (or overridden) Claude Code CLI against a fake + Anthropic API server, captures every request body it sends, and + asserts that none of them contain the two known OpenRouter-breaking + features (`tool_reference` content blocks or the + `context-management-2025-06-27` beta header). + + Why this matters: pinning the CLI version via + ``test_bundled_cli_version_is_known_good_against_openrouter`` only + catches accidental SDK bumps — it doesn't tell us *why* the new + version would fail. This test reproduces the exact mechanism so + bisecting via CI commits gives an actionable signal. + """ + cli_path = _resolve_cli_path() + if cli_path is None or not cli_path.is_file(): + pytest.skip( + "No Claude Code CLI binary available (neither bundled nor " + "overridden via CLAUDE_AGENT_CLI_PATH); cannot reproduce." + ) + + captured: list[_CapturedRequest] = [] + runner, port = await _start_fake_anthropic_server(captured) + try: + returncode, stdout, stderr = await _run_cli_against_fake_server( + cli_path=cli_path, + fake_server_port=port, + timeout_seconds=30.0, + ) + finally: + await runner.cleanup() + + # We don't assert the CLI's exit code — depending on the CLI version + # and what we send back, the CLI may exit non-zero after a single + # successful round-trip. All we care about is that the captured + # request bodies don't contain the forbidden patterns. + logger.info( + "CLI exited rc=%d; captured %d requests; stdout=%d bytes; stderr=%d bytes", + returncode, + len(captured), + len(stdout), + len(stderr), + ) + + if not captured: + pytest.skip( + "Bundled CLI did not make any HTTP requests to the fake server " + f"(rc={returncode}). The CLI may have failed before reaching " + f"the network — stderr tail: {stderr[-500:]!r}. " + "Nothing to assert; treating as inconclusive rather than " + "either passing or failing." + ) + + all_findings: list[str] = [] + for req in captured: + findings = _scan_request_for_forbidden_patterns(req.body, req.headers) + if findings: + all_findings.extend(f"{req.path}: {finding}" for finding in findings) + + assert not all_findings, ( + f"Bundled Claude Code CLI sent OpenRouter-incompatible features in " + f"{len(all_findings)} request(s):\n - " + + "\n - ".join(all_findings) + + "\n\nThis is the regression that prevents us from upgrading " + "`claude-agent-sdk` above 0.1.45. See " + "https://github.com/Significant-Gravitas/AutoGPT/pull/12294 and " + "https://github.com/anthropics/claude-agent-sdk-python/issues/789. " + "If you intended to upgrade, you must use a known-good CLI binary " + "via `claude_agent_cli_path` (env: `CLAUDE_AGENT_CLI_PATH`) " + "instead of the bundled one." + ) + + +def test_subprocess_module_available(): + """Sentinel test: the subprocess module must be importable so the + main reproduction test can spawn the CLI. Catches sandboxed CI + runners that block subprocess execution before the slow test runs.""" + assert subprocess.__name__ == "subprocess" diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index 45a7cf4434..0d949b93fa 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -196,3 +196,79 @@ def test_sdk_exports_hook_event_type(hook_event: str): # HookEvent is a Literal type — check that our events are valid values. # We can't easily inspect Literal at runtime, so just verify the type exists. assert HookEvent is not None + + +# --------------------------------------------------------------------------- +# OpenRouter compatibility — bundled CLI version pin +# --------------------------------------------------------------------------- +# +# We're stuck on ``claude-agent-sdk==0.1.45`` (bundled CLI ``2.1.63``) +# because every version above introduces a 400 against OpenRouter: +# +# 1. CLI ``2.1.69`` (= SDK ``0.1.46``) shipped a `tool_reference` content +# block in `tool_result.content` that OpenRouter's stricter Zod +# validation rejects. See PR +# https://github.com/Significant-Gravitas/AutoGPT/pull/12294 for the +# forensic write-up that originally pinned us. CLI ``2.1.70`` added +# proxy detection that *should* disable the offending block, but two +# later attempts (Dependabot bumps to 0.1.55 / 0.1.56) still failed. +# +# 2. A second regression — the ``context-management-2025-06-27`` beta +# header — appeared in some CLI version after ``2.1.91``. Tracked +# upstream at +# https://github.com/anthropics/claude-agent-sdk-python/issues/789 +# (still open at the time of writing, no upstream PR yet). +# +# This test is the cheapest possible regression guard: it pins the +# bundled CLI to a known-good version. If anyone bumps +# ``claude-agent-sdk`` in ``pyproject.toml``, the bundled CLI version in +# ``_cli_version.py`` will change and this test will fail with a clear +# message that points the next person at the OpenRouter compat issue +# instead of letting them silently re-break production. +# +# Workaround for actually upgrading: set the +# ``claude_agent_cli_path`` config option (or the matching env var) to +# point at a separately-installed Claude Code CLI binary at a known-good +# version, so the SDK Python API surface and the CLI binary version can +# be picked independently. + +# CLI versions verified to work against OpenRouter from production +# traffic. When upstream lands a fix and we can confirm a newer version +# works, add it to this set rather than blanket-removing the assertion. +_KNOWN_GOOD_BUNDLED_CLI_VERSIONS: frozenset[str] = frozenset({"2.1.63"}) + + +def test_bundled_cli_version_is_known_good_against_openrouter(): + """Pin the bundled CLI version so accidental SDK bumps cause a loud, + fast failure with a pointer to the OpenRouter compatibility issue.""" + from claude_agent_sdk._cli_version import __cli_version__ + + assert __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS, ( + f"Bundled Claude Code CLI version is {__cli_version__!r}, which is " + f"not in the OpenRouter-known-good set " + f"{sorted(_KNOWN_GOOD_BUNDLED_CLI_VERSIONS)!r}. " + "If you intentionally bumped `claude-agent-sdk`, verify the new " + "bundled CLI works with OpenRouter against the reproduction test " + "in `cli_openrouter_compat_test.py`, then add the new CLI version " + "to `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS`. If you cannot make the " + "bundled CLI work, set `claude_agent_cli_path` to a known-good " + "binary instead and skip the bundled one. See " + "https://github.com/anthropics/claude-agent-sdk-python/issues/789 " + "and https://github.com/Significant-Gravitas/AutoGPT/pull/12294." + ) + + +def test_sdk_exposes_cli_path_option(): + """Sanity-check that the SDK still exposes the `cli_path` option we use + for the OpenRouter workaround. If upstream removes it we need to know.""" + import inspect + + from claude_agent_sdk import ClaudeAgentOptions + + sig = inspect.signature(ClaudeAgentOptions) + assert "cli_path" in sig.parameters, ( + "ClaudeAgentOptions no longer accepts `cli_path` — our " + "claude_agent_cli_path config override would be silently ignored. " + "Either find an alternative override mechanism or pin the SDK to a " + "version that still exposes it." + ) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 23f8041d53..2f8bd35b01 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2245,6 +2245,12 @@ async def stream_chat_completion_sdk( sdk_options_kwargs["env"] = sdk_env if use_resume and resume_file: sdk_options_kwargs["resume"] = resume_file + # Optional explicit Claude Code CLI binary path (decouples the + # bundled SDK version from the CLI version we run — needed because + # the CLI bundled in 0.1.46+ is broken against OpenRouter). Falls + # back to the bundled binary when unset. + if config.claude_agent_cli_path: + sdk_options_kwargs["cli_path"] = config.claude_agent_cli_path options = ClaudeAgentOptions(**sdk_options_kwargs) # type: ignore[arg-type] # dynamic kwargs From feb247d56e5c9cede50b62bbf349c639b848ecce Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 07:10:55 +0000 Subject: [PATCH 02/34] chore(backend): drop stray blank line in platform_cost_test.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pre-existing dev-branch lint issue from PR #12739 — black would reformat this file (extra blank line between two test classes), which fails the `lint` CI job for any PR branched from current dev. --- autogpt_platform/backend/backend/data/platform_cost_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/autogpt_platform/backend/backend/data/platform_cost_test.py b/autogpt_platform/backend/backend/data/platform_cost_test.py index dacd2c42ea..4a2372628b 100644 --- a/autogpt_platform/backend/backend/data/platform_cost_test.py +++ b/autogpt_platform/backend/backend/data/platform_cost_test.py @@ -35,7 +35,6 @@ class TestUsdToMicrodollars: assert usd_to_microdollars(1.0) == 1_000_000 - class TestMaskEmail: def test_typical_email(self): assert _mask_email("user@example.com") == "us***@example.com" From d6f0fcb052b472c881553692fe1cb395434a2786 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 07:57:04 +0000 Subject: [PATCH 03/34] test(copilot/sdk-compat): unit-test the forbidden-pattern scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add direct unit tests for `_scan_request_for_forbidden_patterns` and `_resolve_cli_path` so the helper logic stays exercised even on CI runs where the slow end-to-end CLI subprocess test can't capture a request (sandboxed runner, missing CLI binary, etc). Brings codecov/patch coverage above the 80% gate. No production code changes — tests only. --- .../copilot/sdk/cli_openrouter_compat_test.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index b55e6b9a66..9aca1b5955 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -422,3 +422,101 @@ def test_subprocess_module_available(): main reproduction test can spawn the CLI. Catches sandboxed CI runners that block subprocess execution before the slow test runs.""" assert subprocess.__name__ == "subprocess" + + +# --------------------------------------------------------------------------- +# Pure helper unit tests — pin the forbidden-pattern detection so any +# future drift in the scanner is caught fast, even when the slow +# end-to-end CLI subprocess test isn't runnable. +# --------------------------------------------------------------------------- + + +class TestScanRequestForForbiddenPatterns: + def test_clean_body_returns_empty_findings(self): + body = '{"model": "claude-opus-4.6", "messages": [{"role": "user", "content": "hi"}]}' + assert _scan_request_for_forbidden_patterns(body, {}) == [] + + def test_detects_tool_reference_in_body(self): + body = ( + '{"messages": [{"role": "user", "content": [' + '{"type": "tool_reference", "tool_name": "find"}' + "]}]}" + ) + findings = _scan_request_for_forbidden_patterns(body, {}) + assert len(findings) == 1 + assert "tool_reference" in findings[0] + assert "PR #12294" in findings[0] + + def test_detects_context_management_in_body(self): + body = '{"betas": ["context-management-2025-06-27"]}' + findings = _scan_request_for_forbidden_patterns(body, {}) + assert len(findings) == 1 + assert "context-management-2025-06-27" in findings[0] + assert "#789" in findings[0] + + def test_detects_context_management_in_anthropic_beta_header(self): + findings = _scan_request_for_forbidden_patterns( + body_text="{}", + headers={"anthropic-beta": "context-management-2025-06-27"}, + ) + assert len(findings) == 1 + assert "anthropic-beta" in findings[0] + + def test_detects_context_management_in_uppercase_header_name(self): + # HTTP header names are case-insensitive — make sure the + # scanner handles a server that didn't normalise names. + findings = _scan_request_for_forbidden_patterns( + body_text="{}", + headers={"Anthropic-Beta": "context-management-2025-06-27, other"}, + ) + assert len(findings) == 1 + + def test_ignores_unrelated_header_values(self): + findings = _scan_request_for_forbidden_patterns( + body_text="{}", + headers={ + "authorization": "Bearer secret", + "anthropic-beta": "fine-grained-tool-streaming-2025", + }, + ) + assert findings == [] + + def test_detects_both_patterns_simultaneously(self): + body = ( + '{"betas": ["context-management-2025-06-27"], ' + '"messages": [{"role": "user", "content": [' + '{"type": "tool_reference", "tool_name": "find"}' + "]}]}" + ) + findings = _scan_request_for_forbidden_patterns(body, {}) + # Both patterns hit, in stable order: tool_reference then betas. + assert len(findings) == 2 + assert "tool_reference" in findings[0] + assert "context-management-2025-06-27" in findings[1] + + +class TestResolveCliPath: + def test_honours_explicit_env_var_when_file_exists(self, tmp_path, monkeypatch): + fake_cli = tmp_path / "fake-claude" + fake_cli.write_text("#!/bin/sh\necho fake\n") + fake_cli.chmod(0o755) + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(fake_cli)) + resolved = _resolve_cli_path() + assert resolved == fake_cli + + def test_returns_none_when_env_var_points_to_missing_file(self, monkeypatch): + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", "/nonexistent/path/to/claude") + # Should fall through to the bundled binary OR return None, + # but never raise. + resolved = _resolve_cli_path() + # We can't assert exact value (depends on whether the bundled + # CLI is installed in the test env) but the function must not + # raise — the caller is supposed to handle None gracefully. + assert resolved is None or resolved.is_file() + + def test_falls_back_to_bundled_when_env_var_unset(self, monkeypatch): + monkeypatch.delenv("CLAUDE_AGENT_CLI_PATH", raising=False) + # Same caveat as above — returns the bundled path or None, + # depending on what's installed in the test env. + resolved = _resolve_cli_path() + assert resolved is None or resolved.is_file() From a6e306d28a39f749af7f0807536bb56b497c5fb6 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:11:47 +0000 Subject: [PATCH 04/34] fix(copilot): accept unprefixed CLAUDE_AGENT_CLI_PATH in config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new `claude_agent_cli_path` field inherited the `CHAT_` Pydantic prefix from `ChatConfig`, so the documented `CLAUDE_AGENT_CLI_PATH` env var was silently ignored — operators following the PR description or the field docstring would set the unprefixed form and the config would fall back to the bundled CLI. Add a `field_validator` that reads `CHAT_CLAUDE_AGENT_CLI_PATH` first and falls back to the unprefixed `CLAUDE_AGENT_CLI_PATH`, matching the same pattern already used by `api_key` and `base_url`. The test helper `_resolve_cli_path` in `cli_openrouter_compat_test.py` mirrors the same two-name lookup so the reproduction test picks up the override regardless of which form is set, and a new test covers the prefixed variant explicitly. Flagged by sentry review on #12741 (thread IDs 3067725580 and 3067768817) as two instances of the same bug. --- .../backend/backend/copilot/config.py | 24 +++++++++- .../copilot/sdk/cli_openrouter_compat_test.py | 45 +++++++++++++++---- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 7cbe268f34..6beb27d843 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -182,7 +182,9 @@ class ChatConfig(BaseSettings): "features (the bundled CLI version in 0.1.46+ is broken against " "OpenRouter — see PR #12294 and " "anthropics/claude-agent-sdk-python#789). Falls back to the " - "bundled binary when unset.", + "bundled binary when unset. Reads from `CHAT_CLAUDE_AGENT_CLI_PATH` " + "or the unprefixed `CLAUDE_AGENT_CLI_PATH` environment variable " + "(same pattern as `api_key` / `base_url`).", ) use_openrouter: bool = Field( default=True, @@ -306,6 +308,26 @@ class ChatConfig(BaseSettings): v = OPENROUTER_BASE_URL return v + @field_validator("claude_agent_cli_path", mode="before") + @classmethod + def get_claude_agent_cli_path(cls, v): + """Resolve the Claude Code CLI override path from environment. + + Accepts either the Pydantic-prefixed ``CHAT_CLAUDE_AGENT_CLI_PATH`` + or the unprefixed ``CLAUDE_AGENT_CLI_PATH`` (matching the same + fallback pattern used by ``api_key`` / ``base_url``). Keeping the + unprefixed form working is important because the field is + primarily an operator escape hatch set via container/host env, + and the unprefixed name is what the PR description, the field + docstrings, and the reproduction test in + ``cli_openrouter_compat_test.py`` refer to. + """ + if not v: + v = os.getenv("CHAT_CLAUDE_AGENT_CLI_PATH") + if not v: + v = os.getenv("CLAUDE_AGENT_CLI_PATH") + return v + # Prompt paths for different contexts PROMPT_PATHS: dict[str, str] = { "default": "prompts/chat_system.md", diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 9aca1b5955..56b8bc2dd6 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -240,12 +240,19 @@ async def _start_fake_anthropic_server( def _resolve_cli_path() -> Path | None: """Return the Claude Code CLI binary the SDK would use. - Honours the same override mechanism as ``service.py``: explicit - ``CLAUDE_AGENT_CLI_PATH`` env var first (matching the new - ``ChatConfig.claude_agent_cli_path`` field), then the bundled - binary that ships with the installed ``claude-agent-sdk`` wheel. + Honours the same override mechanism as ``service.py`` / + ``ChatConfig.claude_agent_cli_path``: checks either the Pydantic- + prefixed ``CHAT_CLAUDE_AGENT_CLI_PATH`` or the unprefixed + ``CLAUDE_AGENT_CLI_PATH`` env var first, then falls back to the + bundled binary that ships with the installed ``claude-agent-sdk`` + wheel. The two env var names are accepted at the config layer via + ``ChatConfig.get_claude_agent_cli_path`` and mirrored here so the + reproduction test picks up the same override regardless of which + form an operator sets. """ - override = os.environ.get("CLAUDE_AGENT_CLI_PATH") + override = os.environ.get("CHAT_CLAUDE_AGENT_CLI_PATH") or os.environ.get( + "CLAUDE_AGENT_CLI_PATH" + ) if override: candidate = Path(override) return candidate if candidate.is_file() else None @@ -362,7 +369,8 @@ async def test_cli_does_not_send_openrouter_incompatible_features(caplog): if cli_path is None or not cli_path.is_file(): pytest.skip( "No Claude Code CLI binary available (neither bundled nor " - "overridden via CLAUDE_AGENT_CLI_PATH); cannot reproduce." + "overridden via CLAUDE_AGENT_CLI_PATH / " + "CHAT_CLAUDE_AGENT_CLI_PATH); cannot reproduce." ) captured: list[_CapturedRequest] = [] @@ -412,8 +420,8 @@ async def test_cli_does_not_send_openrouter_incompatible_features(caplog): "https://github.com/Significant-Gravitas/AutoGPT/pull/12294 and " "https://github.com/anthropics/claude-agent-sdk-python/issues/789. " "If you intended to upgrade, you must use a known-good CLI binary " - "via `claude_agent_cli_path` (env: `CLAUDE_AGENT_CLI_PATH`) " - "instead of the bundled one." + "via `claude_agent_cli_path` (env: `CLAUDE_AGENT_CLI_PATH` or " + "`CHAT_CLAUDE_AGENT_CLI_PATH`) instead of the bundled one." ) @@ -500,11 +508,31 @@ class TestResolveCliPath: fake_cli = tmp_path / "fake-claude" fake_cli.write_text("#!/bin/sh\necho fake\n") fake_cli.chmod(0o755) + monkeypatch.delenv("CHAT_CLAUDE_AGENT_CLI_PATH", raising=False) monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(fake_cli)) resolved = _resolve_cli_path() assert resolved == fake_cli + def test_honours_chat_prefixed_env_var_when_file_exists( + self, tmp_path, monkeypatch + ): + """The Pydantic ``CHAT_`` prefix variant is also honoured. + + Mirrors ``ChatConfig.get_claude_agent_cli_path`` which accepts + either ``CHAT_CLAUDE_AGENT_CLI_PATH`` (prefix applied by + ``pydantic_settings``) or the unprefixed ``CLAUDE_AGENT_CLI_PATH`` + form documented in the PR and field docstring. + """ + fake_cli = tmp_path / "fake-claude-prefixed" + fake_cli.write_text("#!/bin/sh\necho fake\n") + fake_cli.chmod(0o755) + monkeypatch.delenv("CLAUDE_AGENT_CLI_PATH", raising=False) + monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", str(fake_cli)) + resolved = _resolve_cli_path() + assert resolved == fake_cli + def test_returns_none_when_env_var_points_to_missing_file(self, monkeypatch): + monkeypatch.delenv("CHAT_CLAUDE_AGENT_CLI_PATH", raising=False) monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", "/nonexistent/path/to/claude") # Should fall through to the bundled binary OR return None, # but never raise. @@ -516,6 +544,7 @@ class TestResolveCliPath: def test_falls_back_to_bundled_when_env_var_unset(self, monkeypatch): monkeypatch.delenv("CLAUDE_AGENT_CLI_PATH", raising=False) + monkeypatch.delenv("CHAT_CLAUDE_AGENT_CLI_PATH", raising=False) # Same caveat as above — returns the bundled path or None, # depending on what's installed in the test env. resolved = _resolve_cli_path() From 0f00972efcdeae47a73974a9a56f6263b20e21d5 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 07:42:08 +0000 Subject: [PATCH 05/34] feat(copilot): in-process OpenRouter compat proxy for newer Claude SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code CLI in any `claude-agent-sdk` version above 0.1.47 sends the `context-management-2025-06-27` beta header / body field that OpenRouter rejects with HTTP 400. This blocks us from upgrading to take features we want (`exclude_dynamic_sections` cross-user prompt caching in 0.1.57, `AssistantMessage.usage` per-turn token tracking in 0.1.49, the MCP large-tool-result truncation fix in 0.1.55, etc). Tracked upstream at anthropics/claude-agent-sdk-python#789, no fix released yet. This commit adds an in-process HTTP middleware that lets the latest SDK / CLI talk to OpenRouter unchanged. The proxy: * listens on `127.0.0.1:RANDOM_PORT`, * receives every CLI request that would normally go to `ANTHROPIC_BASE_URL`, * strips `tool_reference` content blocks (the original 0.1.46+ regression — defensive, in case the CLI 2.1.70 proxy detection ever regresses) and `context-management-2025-06-27` from both the request body's `betas` array and the `anthropic-beta` header, * forwards the cleaned request upstream and streams the response back unchanged. Wired via `ChatConfig.claude_agent_use_compat_proxy` (default `False`, opt-in). When the flag is on, the SDK service starts a proxy per session, injects its local URL into the spawned CLI subprocess `env` as `ANTHROPIC_BASE_URL`, and tears it down in the session's `finally` block. The proxy is intentionally orthogonal to the existing `claude_agent_cli_path` override: * `cli_path` picks **which** CLI binary we run. * compat proxy rewrites **whatever the chosen binary sends**. Both can be combined or used independently. Tests cover: * the pure stripping helpers (`strip_tool_reference_blocks`, `strip_forbidden_betas_from_body`, `strip_forbidden_anthropic_beta_header`, `clean_request_body_bytes`, `clean_request_headers`) including edge cases like empty input, non-JSON bodies, and the hop-by-hop header set, * end-to-end behaviour against a fake upstream server: stripping the `tool_reference` block in nested `tool_result.content`, rewriting the `anthropic-beta` header, removing the forbidden token from the body `betas` array, passing through clean requests unchanged, and returning a clear 502 on upstream failure (no infinite hang). --- .../backend/backend/copilot/config.py | 11 + .../copilot/sdk/openrouter_compat_proxy.py | 382 ++++++++++++++ .../sdk/openrouter_compat_proxy_test.py | 470 ++++++++++++++++++ .../backend/backend/copilot/sdk/service.py | 60 +++ 4 files changed, 923 insertions(+) create mode 100644 autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py create mode 100644 autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 6beb27d843..979da2c16e 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -186,6 +186,17 @@ class ChatConfig(BaseSettings): "or the unprefixed `CLAUDE_AGENT_CLI_PATH` environment variable " "(same pattern as `api_key` / `base_url`).", ) + claude_agent_use_compat_proxy: bool = Field( + default=False, + description="Run the in-process OpenRouter compatibility proxy " + "(`backend.copilot.sdk.openrouter_compat_proxy`) in front of the " + "Claude Code CLI. The proxy strips `tool_reference` content " + "blocks and the `context-management-2025-06-27` beta header / " + "field from outgoing requests so newer SDK / CLI versions stop " + "tripping OpenRouter's stricter validation. Orthogonal to " + "`claude_agent_cli_path` — the override picks the binary, the " + "proxy rewrites whatever the binary sends.", + ) use_openrouter: bool = Field( default=True, description="Enable routing API calls through the OpenRouter proxy. " diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py new file mode 100644 index 0000000000..600de018bd --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -0,0 +1,382 @@ +"""Tiny in-process HTTP middleware that makes the Claude Code CLI work +against OpenRouter on **any** ``claude-agent-sdk`` version. + +Background +---------- +We've been pinned at ``claude-agent-sdk==0.1.45`` (bundled CLI 2.1.63) +since `PR #12294`_ because every newer CLI version sends one of two +features that OpenRouter rejects: + +1. **`tool_reference` content blocks** in ``tool_result.content`` — + introduced in CLI 2.1.69. OpenRouter's stricter Zod validation + refuses requests containing them with:: + + messages[N].content[0].content: Invalid input: expected string, received array + +2. **`context-management-2025-06-27` beta header** — sent in either the + request body's ``betas`` array or the ``anthropic-beta`` HTTP header. + OpenRouter responds:: + + 400 No endpoints available that support Anthropic's context + management features (context-management-2025-06-27). + + Tracked upstream at `claude-agent-sdk-python#789`_. + +This module starts a tiny aiohttp server that: + +* listens on ``127.0.0.1:RANDOM_PORT``, +* receives every CLI request that would normally go to + ``ANTHROPIC_BASE_URL``, +* strips the two forbidden patterns from the body and headers, +* forwards the cleaned request to the real upstream + (``proxy_target_base_url``, e.g. ``https://openrouter.ai/api/v1``), +* streams the response back to the CLI unchanged. + +The proxy is wired via :class:`backend.copilot.config.ChatConfig.claude_agent_use_compat_proxy`. +When the flag is on, :mod:`backend.copilot.sdk.service` starts a proxy +per session, sets ``ANTHROPIC_BASE_URL`` in the SDK's ``env`` to point +at the proxy, then tears it down after the session ends. + +Why a separate proxy instead of a custom HTTP transport in the SDK? +------------------------------------------------------------------- +The Python SDK delegates **all** HTTP traffic to the bundled Claude +Code CLI subprocess. Once the CLI is spawned, the only seam left is +the network — there is no in-process hook for "modify outgoing +request before it leaves the CLI". The proxy lives at that seam. + +This module is intentionally orthogonal to the +:attr:`ChatConfig.claude_agent_cli_path` override: + +* ``cli_path`` lets us swap **which CLI binary** we run. +* this proxy lets us **rewrite what any CLI binary sends**. + +The two can be combined or used independently. + +.. _PR #12294: https://github.com/Significant-Gravitas/AutoGPT/pull/12294 +.. _claude-agent-sdk-python#789: https://github.com/anthropics/claude-agent-sdk-python/issues/789 +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +import aiohttp +from aiohttp import web + +logger = logging.getLogger(__name__) + +# Header values OpenRouter rejects. We strip exactly these tokens from +# the comma-separated ``anthropic-beta`` header value (preserving any +# other betas the CLI requests). +_FORBIDDEN_BETA_TOKENS: frozenset[str] = frozenset( + { + "context-management-2025-06-27", + } +) + +# Hop-by-hop headers we must NOT forward through the proxy. Per +# RFC 7230 §6.1, these are connection-specific and must be regenerated +# by each intermediary. ``host`` is also stripped because aiohttp +# generates the correct ``Host`` header for the upstream URL itself. +_HOP_BY_HOP_HEADERS: frozenset[str] = frozenset( + { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + # ``content-length`` is stripped because we may rewrite the + # body — aiohttp will recompute it on the upstream request. + "content-length", + } +) + + +# --------------------------------------------------------------------------- +# Pure helpers — exported so the unit tests can drive them directly without +# spinning up a server. +# --------------------------------------------------------------------------- + + +def strip_tool_reference_blocks(payload: Any) -> Any: + """Recursively remove ``tool_reference`` content blocks from + *payload*, returning the cleaned structure. + + The CLI's built-in ``ToolSearch`` tool emits these as part of + ``tool_result.content``:: + + {"type": "tool_reference", "tool_name": "mcp__copilot__find_block"} + + OpenRouter's stricter Zod validation rejects them. Removing them + is safe — they are metadata about which tools were searched, not + real model-visible content. The CLI's *internal* state still + contains them; only the wire format is rewritten. + """ + if isinstance(payload, dict): + # Drop the dict entirely if it IS a tool_reference block. The + # caller (a list comprehension below) discards None entries so + # we can return None to signal "remove me". + if payload.get("type") == "tool_reference": + return None + cleaned_dict: dict[str, Any] = {} + for key, value in payload.items(): + cleaned_value = strip_tool_reference_blocks(value) + cleaned_dict[key] = cleaned_value + return cleaned_dict + if isinstance(payload, list): + cleaned_list: list[Any] = [] + for item in payload: + cleaned_item = strip_tool_reference_blocks(item) + if cleaned_item is None and isinstance(item, dict): + # Item was a tool_reference block — drop it from the + # list rather than leaving a None hole. + continue + cleaned_list.append(cleaned_item) + return cleaned_list + return payload + + +def strip_forbidden_betas_from_body(payload: Any) -> Any: + """Remove forbidden tokens from the ``betas`` array of an + Anthropic Messages API request body, if present. + + The Messages API accepts a top-level ``betas: list[str]`` parameter + used to opt into beta features. We drop tokens in + :data:`_FORBIDDEN_BETA_TOKENS` so OpenRouter's check passes. + """ + if not isinstance(payload, dict): + return payload + betas = payload.get("betas") + if isinstance(betas, list): + cleaned_betas = [b for b in betas if b not in _FORBIDDEN_BETA_TOKENS] + if cleaned_betas: + payload["betas"] = cleaned_betas + else: + # Drop the empty array entirely so OpenRouter doesn't even + # see an empty `betas` field. + payload.pop("betas", None) + return payload + + +def strip_forbidden_anthropic_beta_header(value: str | None) -> str | None: + """Return *value* with forbidden tokens removed. + + The ``anthropic-beta`` HTTP header is a comma-separated list of + feature flags. We strip exactly the forbidden tokens, preserving + any others. Returns ``None`` if nothing remains (so the caller + can drop the header entirely). + """ + if not value: + return value + tokens = [token.strip() for token in value.split(",")] + kept = [token for token in tokens if token and token not in _FORBIDDEN_BETA_TOKENS] + if not kept: + return None + return ", ".join(kept) + + +def clean_request_body_bytes(body_bytes: bytes) -> bytes: + """Apply both body-level strippers to *body_bytes*, returning the + cleaned JSON. Falls back to the original bytes when the body + isn't valid JSON (the CLI shouldn't be sending non-JSON to the + Messages API, but be defensive).""" + if not body_bytes: + return body_bytes + try: + payload = json.loads(body_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return body_bytes + payload = strip_tool_reference_blocks(payload) + payload = strip_forbidden_betas_from_body(payload) + return json.dumps(payload, separators=(",", ":")).encode("utf-8") + + +def clean_request_headers(headers: dict[str, str]) -> dict[str, str]: + """Drop hop-by-hop headers and rewrite ``anthropic-beta`` to remove + forbidden tokens. Returns a fresh dict the caller can pass through + to the upstream client without further mutation. + + Callers should pass an already-materialised ``dict`` (e.g. + ``dict(request.headers)``) so this function stays simple. + """ + cleaned: dict[str, str] = {} + for name, value in headers.items(): + if name.lower() in _HOP_BY_HOP_HEADERS: + continue + if name.lower() == "anthropic-beta": + stripped = strip_forbidden_anthropic_beta_header(value) + if stripped is None: + continue + cleaned[name] = stripped + continue + cleaned[name] = value + return cleaned + + +# --------------------------------------------------------------------------- +# The proxy server +# --------------------------------------------------------------------------- + + +class OpenRouterCompatProxy: + """In-process HTTP proxy that rewrites Claude Code CLI requests on + the way to OpenRouter (or any other Anthropic-compatible gateway). + + Usage:: + + proxy = OpenRouterCompatProxy(target_base_url="https://openrouter.ai/api/v1") + await proxy.start() + try: + # Spawn the CLI with ANTHROPIC_BASE_URL=proxy.local_url + ... + finally: + await proxy.stop() + """ + + def __init__( + self, + target_base_url: str, + *, + bind_host: str = "127.0.0.1", + request_timeout: float = 600.0, + ) -> None: + self._target_base_url = target_base_url.rstrip("/") + self._bind_host = bind_host + self._request_timeout = request_timeout + self._runner: web.AppRunner | None = None + self._client: aiohttp.ClientSession | None = None + self._port: int | None = None + + @property + def local_url(self) -> str: + """The ``http://host:port`` URL that the CLI should use as + ``ANTHROPIC_BASE_URL``. Raises if :meth:`start` has not been + called yet.""" + if self._port is None: + raise RuntimeError("Proxy is not running — call start() first.") + return f"http://{self._bind_host}:{self._port}" + + @property + def target_base_url(self) -> str: + """The upstream URL the proxy is forwarding to.""" + return self._target_base_url + + async def start(self) -> None: + """Bind to a random local port and start serving.""" + if self._runner is not None: + return # already started + self._client = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=self._request_timeout) + ) + app = web.Application() + # Catch every method + path so we can also forward GETs + # (the CLI may probe profile / model endpoints). + app.router.add_route("*", "/{tail:.*}", self._handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, self._bind_host, 0) + await site.start() + server = site._server + if server is None: + await runner.cleanup() + await self._client.close() + raise RuntimeError("Failed to bind compat proxy server.") + sockets = getattr(server, "sockets", None) + if not sockets: + await runner.cleanup() + await self._client.close() + raise RuntimeError("Compat proxy server has no listening sockets.") + self._port = sockets[0].getsockname()[1] + self._runner = runner + logger.info( + "OpenRouter compat proxy listening on %s -> %s", + self.local_url, + self._target_base_url, + ) + + async def stop(self) -> None: + """Stop accepting connections and release the port.""" + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + if self._client is not None: + await self._client.close() + self._client = None + self._port = None + + async def __aenter__(self) -> "OpenRouterCompatProxy": + await self.start() + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.stop() + + async def _handle(self, request: web.Request) -> web.StreamResponse: + """Forward *request* to the upstream after stripping forbidden + features. Streams the upstream response back to the caller + chunk-by-chunk so SSE / streamed responses work.""" + if self._client is None: + raise web.HTTPInternalServerError(reason="proxy client missing") + + # Build the upstream URL. ``request.path_qs`` includes the + # query string verbatim. ``request.path`` for ``/v1/messages`` + # is just ``/v1/messages`` — we strip a leading slash and + # concat with the target base URL. + upstream_path = request.path_qs + if not upstream_path.startswith("/"): + upstream_path = "/" + upstream_path + # Allow the target_base_url to itself contain a path (e.g. + # ``https://openrouter.ai/api/v1``). In that case requests to + # ``/v1/messages`` need to become ``/api/v1/messages``, not + # ``/api/v1/v1/messages``. Strip a leading ``/v1`` from the + # incoming path if the target already ends with ``/v1`` (or + # similar API-version segment). + target_base = self._target_base_url + target_lower = target_base.lower() + for prefix in ("/v1",): + if target_lower.endswith(prefix) and upstream_path.startswith(prefix + "/"): + upstream_path = upstream_path[len(prefix) :] + break + upstream_url = f"{target_base}{upstream_path}" + + body_bytes = await request.read() + cleaned_body = clean_request_body_bytes(body_bytes) + cleaned_headers = clean_request_headers(dict(request.headers)) + + try: + upstream_response = await self._client.request( + method=request.method, + url=upstream_url, + data=cleaned_body if cleaned_body else None, + headers=cleaned_headers, + allow_redirects=False, + ) + except aiohttp.ClientError as e: + logger.warning( + "OpenRouter compat proxy upstream error: %s (url=%s)", e, upstream_url + ) + return web.Response(status=502, text=f"upstream error: {e}") + + # Stream the response back unchanged (apart from hop-by-hop + # header filtering). + downstream = web.StreamResponse( + status=upstream_response.status, + headers=clean_request_headers(dict(upstream_response.headers)), + ) + await downstream.prepare(request) + try: + async for chunk in upstream_response.content.iter_any(): + await downstream.write(chunk) + except (aiohttp.ClientError, asyncio.CancelledError) as e: + logger.warning("OpenRouter compat proxy stream interrupted: %s", e) + finally: + upstream_response.release() + await downstream.write_eof() + return downstream diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py new file mode 100644 index 0000000000..ca2a0725f6 --- /dev/null +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py @@ -0,0 +1,470 @@ +"""Tests for the OpenRouter compatibility proxy. + +The proxy strips two known forbidden patterns from requests so newer +``claude-agent-sdk`` / Claude Code CLI versions can talk to OpenRouter +through the unchanged transport. These tests cover both: + +* the pure stripping helpers (deterministic, no I/O), and +* the end-to-end proxy behaviour against a fake upstream server, so we + catch hop-by-hop header bugs and streaming regressions. + +See ``openrouter_compat_proxy.py`` for the rationale and the upstream +issues being worked around. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import aiohttp +import pytest +from aiohttp import web + +from backend.copilot.sdk.openrouter_compat_proxy import ( + OpenRouterCompatProxy, + _FORBIDDEN_BETA_TOKENS, + _HOP_BY_HOP_HEADERS, + clean_request_body_bytes, + clean_request_headers, + strip_forbidden_anthropic_beta_header, + strip_forbidden_betas_from_body, + strip_tool_reference_blocks, +) + + +# --------------------------------------------------------------------------- +# strip_tool_reference_blocks +# --------------------------------------------------------------------------- + + +class TestStripToolReferenceBlocks: + """The CLI's built-in ToolSearch tool emits ``tool_reference`` + content blocks in ``tool_result.content``. OpenRouter's stricter + Zod validation rejects them. We drop them entirely — they're + metadata about which tools were searched, not real model-visible + content.""" + + def test_removes_tool_reference_block_at_top_level(self): + block = {"type": "tool_reference", "tool_name": "find_block"} + assert strip_tool_reference_blocks(block) is None + + def test_removes_tool_reference_block_from_list(self): + blocks = [ + {"type": "text", "text": "hello"}, + {"type": "tool_reference", "tool_name": "find_block"}, + {"type": "text", "text": "world"}, + ] + assert strip_tool_reference_blocks(blocks) == [ + {"type": "text", "text": "hello"}, + {"type": "text", "text": "world"}, + ] + + def test_strips_nested_tool_reference_inside_tool_result(self): + # The exact shape PR #12294 root-caused: tool_result.content + # contains the tool_reference block. + request = { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [ + {"type": "text", "text": "result text"}, + { + "type": "tool_reference", + "tool_name": "mcp__copilot__find_block", + }, + ], + } + ], + } + ] + } + cleaned = strip_tool_reference_blocks(request) + tool_result_content = cleaned["messages"][0]["content"][0]["content"] + assert tool_result_content == [{"type": "text", "text": "result text"}] + + def test_preserves_unrelated_payloads(self): + payload = { + "model": "claude-opus-4.6", + "messages": [{"role": "user", "content": "hi"}], + "temperature": 0.7, + } + assert strip_tool_reference_blocks(payload) == payload + + def test_handles_empty_and_primitive_inputs(self): + assert strip_tool_reference_blocks({}) == {} + assert strip_tool_reference_blocks([]) == [] + assert strip_tool_reference_blocks("plain string") == "plain string" + assert strip_tool_reference_blocks(42) == 42 + assert strip_tool_reference_blocks(None) is None + + +# --------------------------------------------------------------------------- +# strip_forbidden_betas_from_body +# --------------------------------------------------------------------------- + + +class TestStripForbiddenBetasFromBody: + """OpenRouter rejects ``context-management-2025-06-27`` in the + request body's ``betas`` array.""" + + def test_removes_forbidden_token_keeps_others(self): + body = { + "model": "claude-opus-4.6", + "betas": [ + "context-management-2025-06-27", + "fine-grained-tool-streaming-2025", + ], + } + cleaned = strip_forbidden_betas_from_body(body) + assert cleaned["betas"] == ["fine-grained-tool-streaming-2025"] + + def test_removes_betas_field_entirely_when_only_forbidden(self): + body = {"model": "x", "betas": ["context-management-2025-06-27"]} + cleaned = strip_forbidden_betas_from_body(body) + assert "betas" not in cleaned + + def test_no_op_when_no_betas_field(self): + body = {"model": "x"} + assert strip_forbidden_betas_from_body(body) == {"model": "x"} + + def test_no_op_on_non_dict(self): + assert strip_forbidden_betas_from_body([1, 2, 3]) == [1, 2, 3] + assert strip_forbidden_betas_from_body("plain") == "plain" + + def test_all_forbidden_tokens_constants_are_recognized(self): + for forbidden in _FORBIDDEN_BETA_TOKENS: + body = {"betas": [forbidden, "other"]} + cleaned = strip_forbidden_betas_from_body(body) + assert forbidden not in cleaned["betas"] + + +# --------------------------------------------------------------------------- +# strip_forbidden_anthropic_beta_header +# --------------------------------------------------------------------------- + + +class TestStripForbiddenAnthropicBetaHeader: + def test_removes_forbidden_token_keeps_others(self): + value = "fine-grained-tool-streaming-2025, context-management-2025-06-27, other-beta" + result = strip_forbidden_anthropic_beta_header(value) + assert result == "fine-grained-tool-streaming-2025, other-beta" + + def test_returns_none_when_only_forbidden_token_present(self): + assert ( + strip_forbidden_anthropic_beta_header("context-management-2025-06-27") + is None + ) + + def test_passes_through_clean_header(self): + assert strip_forbidden_anthropic_beta_header("foo, bar") == "foo, bar" + + def test_handles_empty_and_none_input(self): + assert strip_forbidden_anthropic_beta_header("") == "" + assert strip_forbidden_anthropic_beta_header(None) is None + + def test_handles_extra_whitespace(self): + value = " context-management-2025-06-27 , fine-grained " + result = strip_forbidden_anthropic_beta_header(value) + assert result == "fine-grained" + + +# --------------------------------------------------------------------------- +# clean_request_body_bytes — combined body-level cleanup +# --------------------------------------------------------------------------- + + +class TestCleanRequestBodyBytes: + def test_strips_both_patterns_in_one_pass(self): + body = { + "model": "claude-opus-4.6", + "betas": ["context-management-2025-06-27"], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [ + {"type": "tool_reference", "tool_name": "find"}, + {"type": "text", "text": "ok"}, + ], + } + ], + } + ], + } + cleaned_bytes = clean_request_body_bytes(json.dumps(body).encode("utf-8")) + cleaned = json.loads(cleaned_bytes.decode("utf-8")) + assert "betas" not in cleaned # only forbidden token, dropped + tool_result_content = cleaned["messages"][0]["content"][0]["content"] + assert tool_result_content == [{"type": "text", "text": "ok"}] + + def test_passes_through_non_json_body(self): + garbage = b"\xff\xfe not json at all" + assert clean_request_body_bytes(garbage) == garbage + + def test_passes_through_empty_body(self): + assert clean_request_body_bytes(b"") == b"" + + +# --------------------------------------------------------------------------- +# clean_request_headers — hop-by-hop + anthropic-beta cleanup +# --------------------------------------------------------------------------- + + +class TestCleanRequestHeaders: + def test_drops_hop_by_hop_headers(self): + headers = { + "Host": "example.com", + "Connection": "keep-alive", + "Content-Length": "42", + "Authorization": "Bearer xxx", + "Content-Type": "application/json", + } + cleaned = clean_request_headers(headers) + assert "Host" not in cleaned + assert "Connection" not in cleaned + assert "Content-Length" not in cleaned + assert cleaned["Authorization"] == "Bearer xxx" + assert cleaned["Content-Type"] == "application/json" + + def test_strips_forbidden_token_from_anthropic_beta_header(self): + headers = { + "anthropic-beta": "context-management-2025-06-27, other-beta", + "Authorization": "Bearer x", + } + cleaned = clean_request_headers(headers) + assert cleaned["anthropic-beta"] == "other-beta" + + def test_drops_anthropic_beta_header_when_only_forbidden(self): + headers = {"anthropic-beta": "context-management-2025-06-27"} + cleaned = clean_request_headers(headers) + assert "anthropic-beta" not in cleaned + + def test_hop_by_hop_set_completeness(self): + # Sanity check: if upstream removes hop-by-hop headers from + # this set we want to know — keep the canonical RFC 7230 list. + for required in ("connection", "transfer-encoding", "host"): + assert required in _HOP_BY_HOP_HEADERS + + +# --------------------------------------------------------------------------- +# End-to-end: real proxy + fake upstream +# --------------------------------------------------------------------------- + + +class _FakeUpstream: + """Tiny aiohttp app that records every request the proxy forwards + so the test can assert on the cleaned payloads.""" + + def __init__(self) -> None: + self.captured: list[dict[str, Any]] = [] + self._runner: web.AppRunner | None = None + self.port: int = 0 + + async def start(self) -> str: + async def handler(request: web.Request) -> web.StreamResponse: + body = await request.text() + self.captured.append( + { + "method": request.method, + "path": request.path_qs, + "headers": {k: v for k, v in request.headers.items()}, + "body": body, + } + ) + # Return a minimal JSON success response so the proxy has + # something to stream back. + return web.json_response({"ok": True, "echoed": body}) + + app = web.Application() + app.router.add_route("*", "/{tail:.*}", handler) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + server = site._server + assert server is not None + sockets = getattr(server, "sockets", None) + assert sockets is not None + self.port = sockets[0].getsockname()[1] + return f"http://127.0.0.1:{self.port}" + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + +@pytest.mark.asyncio +async def test_proxy_strips_tool_reference_block_end_to_end(): + upstream = _FakeUpstream() + upstream_url = await upstream.start() + proxy = OpenRouterCompatProxy(target_base_url=upstream_url) + await proxy.start() + try: + body = { + "model": "claude-opus-4.6", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hi"}, + { + "type": "tool_reference", + "tool_name": "mcp__copilot__find_block", + }, + ], + } + ], + } + async with aiohttp.ClientSession() as client: + async with client.post( + f"{proxy.local_url}/v1/messages", + json=body, + headers={"Authorization": "Bearer test"}, + ) as resp: + assert resp.status == 200 + await resp.read() + finally: + await proxy.stop() + await upstream.stop() + + assert len(upstream.captured) == 1 + forwarded = json.loads(upstream.captured[0]["body"]) + # The tool_reference block must NOT be in the upstream-visible body. + assert '"tool_reference"' not in upstream.captured[0]["body"] + assert forwarded["messages"][0]["content"] == [{"type": "text", "text": "hi"}] + + +@pytest.mark.asyncio +async def test_proxy_strips_context_management_beta_header_end_to_end(): + upstream = _FakeUpstream() + upstream_url = await upstream.start() + proxy = OpenRouterCompatProxy(target_base_url=upstream_url) + await proxy.start() + try: + async with aiohttp.ClientSession() as client: + async with client.post( + f"{proxy.local_url}/v1/messages", + json={"model": "x", "messages": []}, + headers={ + "Authorization": "Bearer test", + "anthropic-beta": "context-management-2025-06-27, other-beta", + }, + ) as resp: + assert resp.status == 200 + await resp.read() + finally: + await proxy.stop() + await upstream.stop() + + forwarded_headers = upstream.captured[0]["headers"] + # Header is rewritten to remove only the forbidden token, keeping the rest. + assert any( + k.lower() == "anthropic-beta" and v == "other-beta" + for k, v in forwarded_headers.items() + ) + + +@pytest.mark.asyncio +async def test_proxy_strips_betas_from_request_body_end_to_end(): + upstream = _FakeUpstream() + upstream_url = await upstream.start() + proxy = OpenRouterCompatProxy(target_base_url=upstream_url) + await proxy.start() + try: + body = { + "model": "x", + "betas": [ + "context-management-2025-06-27", + "fine-grained-tool-streaming-2025", + ], + "messages": [], + } + async with aiohttp.ClientSession() as client: + async with client.post( + f"{proxy.local_url}/v1/messages", + json=body, + ) as resp: + assert resp.status == 200 + await resp.read() + finally: + await proxy.stop() + await upstream.stop() + + forwarded = json.loads(upstream.captured[0]["body"]) + # Only the surviving beta should be present. + assert forwarded["betas"] == ["fine-grained-tool-streaming-2025"] + + +@pytest.mark.asyncio +async def test_proxy_passes_through_clean_request_unchanged(): + """The proxy must be a no-op for requests that don't contain any of + the forbidden patterns — no other rewriting allowed.""" + upstream = _FakeUpstream() + upstream_url = await upstream.start() + proxy = OpenRouterCompatProxy(target_base_url=upstream_url) + await proxy.start() + try: + body = { + "model": "claude-opus-4.6", + "messages": [{"role": "user", "content": "hello"}], + "temperature": 0.7, + } + async with aiohttp.ClientSession() as client: + async with client.post( + f"{proxy.local_url}/v1/messages", + json=body, + headers={ + "Authorization": "Bearer test", + "Content-Type": "application/json", + }, + ) as resp: + assert resp.status == 200 + await resp.read() + finally: + await proxy.stop() + await upstream.stop() + + forwarded = json.loads(upstream.captured[0]["body"]) + assert forwarded == body + + +@pytest.mark.asyncio +async def test_proxy_returns_502_on_upstream_failure(): + """If the upstream is unreachable the proxy must return a clear + 502, not silently hang.""" + proxy = OpenRouterCompatProxy( + target_base_url="http://127.0.0.1:1", # nothing listening + ) + await proxy.start() + try: + async with aiohttp.ClientSession() as client: + async with client.post( + f"{proxy.local_url}/v1/messages", + json={"model": "x"}, + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + assert resp.status == 502 + except (aiohttp.ClientError, asyncio.TimeoutError): + # Some platforms refuse the connection so quickly aiohttp + # raises before the proxy can respond — that also satisfies + # the spirit of the test (no infinite hang). + pass + finally: + await proxy.stop() + + +@pytest.mark.asyncio +async def test_proxy_local_url_raises_before_start(): + proxy = OpenRouterCompatProxy(target_base_url="http://example.com") + with pytest.raises(RuntimeError): + _ = proxy.local_url diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 2f8bd35b01..d563462218 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -1980,6 +1980,13 @@ async def stream_chat_completion_sdk( transcript_content: str = "" state: _RetryState | None = None + # OpenRouter compat proxy — started inside the try and stopped in finally + # when ``ChatConfig.claude_agent_use_compat_proxy`` is enabled. The proxy + # rewrites outgoing CLI requests to strip ``tool_reference`` content + # blocks and the ``context-management-2025-06-27`` beta so the latest + # SDK / CLI versions stop tripping OpenRouter's validation. + _compat_proxy: Any = None # OpenRouterCompatProxy | None — lazy import + # Token usage accumulators — populated from ResultMessage at end of turn turn_prompt_tokens = 0 # uncached input tokens only turn_completion_tokens = 0 @@ -2241,6 +2248,46 @@ async def stream_chat_completion_sdk( } if sdk_model: sdk_options_kwargs["model"] = sdk_model + + # OpenRouter compatibility proxy — started here so its local URL + # can be injected into the CLI subprocess env BEFORE the env dict + # is passed to ``ClaudeAgentOptions``. When this flag is on we + # transparently rewrite outgoing CLI requests via the proxy + # (stripping ``tool_reference`` blocks and the + # ``context-management-2025-06-27`` beta) so newer SDK / CLI + # versions can talk to OpenRouter without their stricter + # validation rejecting the request. + if config.claude_agent_use_compat_proxy: + from backend.copilot.sdk.openrouter_compat_proxy import ( + OpenRouterCompatProxy, + ) + + # Use the same upstream URL the SDK would have hit directly. + # Prefer an explicit override in ``sdk_env`` (e.g. set by a + # caller wanting to test against a specific gateway), then + # the parent process env, then the platform-wide + # ``OPENROUTER_BASE_URL`` constant. + from backend.util.clients import OPENROUTER_BASE_URL + + target_base_url = ( + (sdk_env or {}).get("ANTHROPIC_BASE_URL") + or os.environ.get("ANTHROPIC_BASE_URL") + or OPENROUTER_BASE_URL + ) + _compat_proxy = OpenRouterCompatProxy(target_base_url=target_base_url) + await _compat_proxy.start() + # Inject the proxy URL into the SDK env so the spawned CLI + # subprocess uses the proxy as its Anthropic endpoint. + if sdk_env is None: + sdk_env = {} + sdk_env["ANTHROPIC_BASE_URL"] = _compat_proxy.local_url + logger.info( + "%s OpenRouter compat proxy active: %s -> %s", + log_prefix, + _compat_proxy.local_url, + _compat_proxy.target_base_url, + ) + if sdk_env: sdk_options_kwargs["env"] = sdk_env if use_resume and resume_file: @@ -2913,5 +2960,18 @@ async def stream_chat_completion_sdk( except Exception: logger.warning("%s SDK cleanup failed", log_prefix, exc_info=True) finally: + # Tear down the OpenRouter compat proxy if it was started for + # this session — releases the bound port and the aiohttp + # client. Wrapped so a stop failure can never block the + # downstream lock release. + if _compat_proxy is not None: + try: + await _compat_proxy.stop() + except Exception: + logger.warning( + "%s OpenRouter compat proxy stop failed", + log_prefix, + exc_info=True, + ) # Release stream lock to allow new streams for this session await lock.release() From 93f27ffdf614f579926b168dfc15712d7c67d555 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 07:55:53 +0000 Subject: [PATCH 06/34] fix(copilot/sdk-proxy): address CodeQL findings + isort drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged two issues in the new compat proxy: 1. `py/clear-text-logging-sensitive-data` (high) — logging `self._target_base_url` could leak credentials if a future caller passed a URL containing them. Switched to logging only the host component (and the local 127.0.0.1 port) so even an accidentally-credentialled base URL stays out of logs. 2. `py/stack-trace-exposure` (medium) — returning the upstream exception text in the 502 response body could leak internal hostnames or stack frames to the client. Changed to a generic "upstream error" string; the detailed exception is still logged server-side. Also fixes an isort sorting drift in the test file (private underscore-prefixed names must sort before public names — local isort accepted the order, CI's isort did not). --- .../copilot/sdk/openrouter_compat_proxy.py | 19 +++++++++++++++---- .../sdk/openrouter_compat_proxy_test.py | 3 +-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index 600de018bd..9e617b4575 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -62,6 +62,7 @@ import asyncio import json import logging from typing import Any +from urllib.parse import urlparse import aiohttp from aiohttp import web @@ -295,10 +296,16 @@ class OpenRouterCompatProxy: raise RuntimeError("Compat proxy server has no listening sockets.") self._port = sockets[0].getsockname()[1] self._runner = runner + # Log only the host of the upstream — never the full URL — so a + # base URL that happens to embed credentials (e.g. via a path + # token, though OpenRouter doesn't do this) cannot leak into + # logs. CodeQL `py/clear-text-logging-sensitive-data` defends + # against this case. + upstream_host = urlparse(self._target_base_url).netloc or "" logger.info( - "OpenRouter compat proxy listening on %s -> %s", - self.local_url, - self._target_base_url, + "OpenRouter compat proxy listening on 127.0.0.1:%d -> %s", + self._port, + upstream_host, ) async def stop(self) -> None: @@ -359,10 +366,14 @@ class OpenRouterCompatProxy: allow_redirects=False, ) except aiohttp.ClientError as e: + # Log the detailed error for ops, but return a generic + # message to the caller — exception strings can leak + # internal hostnames, ports, or stack frames (CodeQL + # `py/stack-trace-exposure`). logger.warning( "OpenRouter compat proxy upstream error: %s (url=%s)", e, upstream_url ) - return web.Response(status=502, text=f"upstream error: {e}") + return web.Response(status=502, text="upstream error") # Stream the response back unchanged (apart from hop-by-hop # header filtering). diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py index ca2a0725f6..46e8817485 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py @@ -23,9 +23,9 @@ import pytest from aiohttp import web from backend.copilot.sdk.openrouter_compat_proxy import ( - OpenRouterCompatProxy, _FORBIDDEN_BETA_TOKENS, _HOP_BY_HOP_HEADERS, + OpenRouterCompatProxy, clean_request_body_bytes, clean_request_headers, strip_forbidden_anthropic_beta_header, @@ -33,7 +33,6 @@ from backend.copilot.sdk.openrouter_compat_proxy import ( strip_tool_reference_blocks, ) - # --------------------------------------------------------------------------- # strip_tool_reference_blocks # --------------------------------------------------------------------------- From fed728e5461406d703688c0ca0d44fc54ef44d50 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 08:05:55 +0000 Subject: [PATCH 07/34] fix(copilot/sdk-proxy): drop upstream from log message entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix logged the parsed netloc instead of the full URL, but CodeQL's `py/clear-text-logging-sensitive-data` taint analysis still traces the value through `urlparse(target_base_url).netloc` and flags the log call. Address by dropping the upstream component from the log entirely — only the local bind port is logged. The upstream endpoint is discoverable from `ChatConfig` and exposed via the `target_base_url` property for callers that need it. --- .../copilot/sdk/openrouter_compat_proxy.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index 9e617b4575..200b6089d4 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -62,7 +62,6 @@ import asyncio import json import logging from typing import Any -from urllib.parse import urlparse import aiohttp from aiohttp import web @@ -296,17 +295,15 @@ class OpenRouterCompatProxy: raise RuntimeError("Compat proxy server has no listening sockets.") self._port = sockets[0].getsockname()[1] self._runner = runner - # Log only the host of the upstream — never the full URL — so a - # base URL that happens to embed credentials (e.g. via a path - # token, though OpenRouter doesn't do this) cannot leak into - # logs. CodeQL `py/clear-text-logging-sensitive-data` defends - # against this case. - upstream_host = urlparse(self._target_base_url).netloc or "" - logger.info( - "OpenRouter compat proxy listening on 127.0.0.1:%d -> %s", - self._port, - upstream_host, - ) + # Deliberately log only the local bind port — never the + # upstream URL or any derived component. CodeQL's + # `py/clear-text-logging-sensitive-data` taint analysis traces + # everything that originates from a config-supplied URL as + # potentially-sensitive even after parsing, and the upstream + # endpoint is anyway discoverable from the config the operator + # already has access to. The detailed upstream is exposed via + # the ``target_base_url`` property for callers that need it. + logger.info("OpenRouter compat proxy listening on 127.0.0.1:%d", self._port) async def stop(self) -> None: """Stop accepting connections and release the port.""" From 0554a0ae35a4acf08e8510978c409b11aadf180e Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:18:50 +0000 Subject: [PATCH 08/34] =?UTF-8?q?fix(copilot/sdk-proxy):=20address=20PR=20?= =?UTF-8?q?review=20=E2=80=94=20RFC=207230=20hop-by-hop,=20timeouts,=20can?= =?UTF-8?q?cellation,=20provider=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all seven review threads on #12745 (coderabbit + sentry) in a single commit because they overlap in the same file cluster: config.py --------- * ``claude_agent_use_compat_proxy`` gains a ``field_validator`` that reads the unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` in addition to the Pydantic-prefixed ``CHAT_`` form, matching the same dual-name pattern already used by ``api_key`` / ``base_url`` / ``claude_agent_cli_path`` and keeping parity with the docstring and the PR description. Without this the operator-facing env var was silently ignored because of ``env_prefix = "CHAT_"``. openrouter_compat_proxy.py -------------------------- * ``_HOP_BY_HOP_HEADERS`` now includes the canonical ``trailer`` (singular per RFC 7230 §4.4) alongside the plural ``trailers``; ``clean_request_headers`` additionally drops every header whose name is listed in the incoming ``Connection`` field value (§6.1 extension hop-by-hop headers), case-insensitively — previously extension hop-by-hop headers could leak upstream. * ``strip_tool_reference_blocks`` now *removes* dict-valued ``tool_reference`` children from their parent dict instead of rewriting them to ``null``; the stated "strip anywhere" semantics were broken on nested dict assignments and still produced schema-invalid payloads upstream. Genuine ``None`` children on non-dict values are still preserved. * ``_handle`` upstream-call error handler now catches ``asyncio.TimeoutError`` alongside ``aiohttp.ClientError`` — ``aiohttp.ClientTimeout`` raises ``asyncio.TimeoutError`` (not ``aiohttp.ClientError``), so hung upstreams used to escape as a generic 500 instead of the documented 502. * Streaming-response handler no longer suppresses ``asyncio.CancelledError``. It's now split into its own except branch, releases the upstream body, and re-raises so cooperative task cancellation works as intended (cancellation while mid-stream was previously being caught alongside ``ClientError`` and silently swallowed, leading to hung request handlers on client disconnects / shutdowns). * ``start()`` wraps the ``runner.setup() / site.start()`` sequence in try/except that tears down both the client session and the (partially-initialised) runner on any exception, so failed startups never leak resources. The attributes are only published to the instance after the full chain succeeds. service.py ---------- * The compat-proxy startup is now gated on there actually being an Anthropic-compatible upstream to forward to. Previously the code fell back to ``OPENROUTER_BASE_URL`` unconditionally, which would silently re-route direct-Anthropic / Claude Code subscription sessions through OpenRouter and break auth. The new gate is: explicit ``ANTHROPIC_BASE_URL`` in ``sdk_env`` or the process env, OR ``ChatConfig.openrouter_active`` (OpenRouter is configured as the session's routing provider). When neither holds we log a warning and skip proxy startup — the feature is opt-in and named "OpenRouter compatibility", so no-oping direct-Anthropic sessions is the safe default. The success log line also drops the upstream URL to match the taint-analysis guidance already applied to ``openrouter_compat_proxy.start``. Tests ----- * Added regression tests for the dict-valued tool_reference fix, the Connection-listed header stripping (with case-insensitive matching), and an end-to-end 502-on-upstream-timeout test (fake upstream that sleeps longer than the proxy's request timeout). The hop-by-hop completeness test now also pins ``trailer`` / ``trailers``. --- .../backend/backend/copilot/config.py | 28 ++++- .../copilot/sdk/openrouter_compat_proxy.py | 114 +++++++++++++---- .../sdk/openrouter_compat_proxy_test.py | 116 +++++++++++++++++- .../backend/backend/copilot/sdk/service.py | 82 +++++++++---- 4 files changed, 290 insertions(+), 50 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 979da2c16e..0c67091a5a 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -195,7 +195,13 @@ class ChatConfig(BaseSettings): "field from outgoing requests so newer SDK / CLI versions stop " "tripping OpenRouter's stricter validation. Orthogonal to " "`claude_agent_cli_path` — the override picks the binary, the " - "proxy rewrites whatever the binary sends.", + "proxy rewrites whatever the binary sends. Reads from " + "`CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY` or the unprefixed " + "`CLAUDE_AGENT_USE_COMPAT_PROXY` environment variable (same " + "pattern as `claude_agent_cli_path`). Only takes effect when " + "the session has an Anthropic-compatible upstream to forward " + "to — direct-Anthropic sessions skip the proxy entirely to " + "avoid silently re-routing through OpenRouter.", ) use_openrouter: bool = Field( default=True, @@ -339,6 +345,26 @@ class ChatConfig(BaseSettings): v = os.getenv("CLAUDE_AGENT_CLI_PATH") return v + @field_validator("claude_agent_use_compat_proxy", mode="before") + @classmethod + def get_claude_agent_use_compat_proxy(cls, v): + """Resolve the compat-proxy opt-in from environment. + + Accepts either ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` (the + Pydantic-prefixed form) or the unprefixed + ``CLAUDE_AGENT_USE_COMPAT_PROXY`` — same dual-name pattern as + ``claude_agent_cli_path`` above and ``api_key`` / ``base_url`` + further up. Returning the raw string lets Pydantic handle the + usual truthy/falsy coercion (``"1"``, ``"true"``, ``"yes"``, + ``"on"`` → True), so operators get the same behaviour they'd + get from the prefixed env var. + """ + if v is None: + v = os.getenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY") + if v is None: + v = os.getenv("CLAUDE_AGENT_USE_COMPAT_PROXY") + return v + # Prompt paths for different contexts PROMPT_PATHS: dict[str, str] = { "default": "prompts/chat_system.md", diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index 200b6089d4..cc654a1396 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -81,6 +81,13 @@ _FORBIDDEN_BETA_TOKENS: frozenset[str] = frozenset( # RFC 7230 §6.1, these are connection-specific and must be regenerated # by each intermediary. ``host`` is also stripped because aiohttp # generates the correct ``Host`` header for the upstream URL itself. +# +# The canonical header name defined in RFC 7230 §4.4 is ``Trailer`` +# (singular); some SDKs / legacy proxies also emit the plural +# ``Trailers`` so we accept both forms just in case. Intermediaries +# must additionally drop every header name listed in the incoming +# ``Connection`` field value (§6.1 "extension hop-by-hop headers") — +# that's handled dynamically by :func:`clean_request_headers`. _HOP_BY_HOP_HEADERS: frozenset[str] = frozenset( { "connection", @@ -88,6 +95,7 @@ _HOP_BY_HOP_HEADERS: frozenset[str] = frozenset( "proxy-authenticate", "proxy-authorization", "te", + "trailer", "trailers", "transfer-encoding", "upgrade", @@ -128,6 +136,13 @@ def strip_tool_reference_blocks(payload: Any) -> Any: cleaned_dict: dict[str, Any] = {} for key, value in payload.items(): cleaned_value = strip_tool_reference_blocks(value) + # If a dict-valued child WAS a tool_reference block, + # drop the key entirely rather than writing `null` — + # otherwise schema-strict upstreams still reject the + # payload. Only applies when the original value was a + # dict; genuine None values in the input are preserved. + if cleaned_value is None and isinstance(value, dict): + continue cleaned_dict[key] = cleaned_value return cleaned_dict if isinstance(payload, list): @@ -203,14 +218,32 @@ def clean_request_headers(headers: dict[str, str]) -> dict[str, str]: forbidden tokens. Returns a fresh dict the caller can pass through to the upstream client without further mutation. + Per RFC 7230 §6.1, intermediaries must drop the static hop-by-hop + set above **and** every header name listed in the incoming + ``Connection`` field value (case-insensitive). The latter is how + extension hop-by-hop headers are signalled per-connection. + Callers should pass an already-materialised ``dict`` (e.g. ``dict(request.headers)``) so this function stays simple. """ + # Parse ``Connection: a, b, c`` into a lowercase token set so we + # can drop any header the sender explicitly marked as hop-by-hop + # on this connection. This is separate from the static set + # above — extension headers can be anything. + connection_header = next( + (value for name, value in headers.items() if name.lower() == "connection"), + "", + ) + connection_tokens: set[str] = { + token.strip().lower() for token in connection_header.split(",") if token.strip() + } + cleaned: dict[str, str] = {} for name, value in headers.items(): - if name.lower() in _HOP_BY_HOP_HEADERS: + lower_name = name.lower() + if lower_name in _HOP_BY_HOP_HEADERS or lower_name in connection_tokens: continue - if name.lower() == "anthropic-beta": + if lower_name == "anthropic-beta": stripped = strip_forbidden_anthropic_beta_header(value) if stripped is None: continue @@ -269,10 +302,17 @@ class OpenRouterCompatProxy: return self._target_base_url async def start(self) -> None: - """Bind to a random local port and start serving.""" + """Bind to a random local port and start serving. + + Cleans up the ``ClientSession`` and the ``AppRunner`` on any + failure during setup so a partially-initialised proxy never + leaves resources dangling (covers the + ``runner.setup() / site.start()`` raise paths in addition to + the explicit bind-failure branches below). + """ if self._runner is not None: return # already started - self._client = aiohttp.ClientSession( + client = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=self._request_timeout) ) app = web.Application() @@ -280,20 +320,35 @@ class OpenRouterCompatProxy: # (the CLI may probe profile / model endpoints). app.router.add_route("*", "/{tail:.*}", self._handle) runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, self._bind_host, 0) - await site.start() - server = site._server - if server is None: - await runner.cleanup() - await self._client.close() - raise RuntimeError("Failed to bind compat proxy server.") - sockets = getattr(server, "sockets", None) - if not sockets: - await runner.cleanup() - await self._client.close() - raise RuntimeError("Compat proxy server has no listening sockets.") - self._port = sockets[0].getsockname()[1] + runner_setup = False + try: + await runner.setup() + runner_setup = True + site = web.TCPSite(runner, self._bind_host, 0) + await site.start() + server = site._server + if server is None: + raise RuntimeError("Failed to bind compat proxy server.") + sockets = getattr(server, "sockets", None) + if not sockets: + raise RuntimeError("Compat proxy server has no listening sockets.") + self._port = sockets[0].getsockname()[1] + except BaseException: + # Best-effort teardown — swallow secondary errors so the + # caller sees the original exception. + if runner_setup: + try: + await runner.cleanup() + except Exception: # pragma: no cover - cleanup-only path + logger.exception("compat proxy runner cleanup failed") + try: + await client.close() + except Exception: # pragma: no cover - cleanup-only path + logger.exception("compat proxy client close failed") + raise + # Only publish the attributes after everything is wired up so + # ``stop()`` and ``local_url`` observe a consistent state. + self._client = client self._runner = runner # Deliberately log only the local bind port — never the # upstream URL or any derived component. CodeQL's @@ -362,7 +417,12 @@ class OpenRouterCompatProxy: headers=cleaned_headers, allow_redirects=False, ) - except aiohttp.ClientError as e: + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + # ``aiohttp.ClientTimeout`` raises ``asyncio.TimeoutError`` + # (not ``aiohttp.ClientError``) on hung upstreams, so both + # must be caught here to surface the explicit 502 failure + # mode this proxy guarantees. + # # Log the detailed error for ops, but return a generic # message to the caller — exception strings can leak # internal hostnames, ports, or stack frames (CodeQL @@ -379,12 +439,24 @@ class OpenRouterCompatProxy: headers=clean_request_headers(dict(upstream_response.headers)), ) await downstream.prepare(request) + cancelled = False try: async for chunk in upstream_response.content.iter_any(): await downstream.write(chunk) - except (aiohttp.ClientError, asyncio.CancelledError) as e: + except asyncio.CancelledError: + # Never suppress cancellation — since Python 3.8 it's a + # ``BaseException`` subclass precisely so catching + # ``Exception`` won't accidentally swallow it. Release + # the upstream body and re-raise so the asyncio task + # cooperatively unwinds (avoids hanging shutdowns / + # stuck request handlers). + cancelled = True + upstream_response.release() + raise + except aiohttp.ClientError as e: logger.warning("OpenRouter compat proxy stream interrupted: %s", e) finally: - upstream_response.release() + if not cancelled: + upstream_response.release() await downstream.write_eof() return downstream diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py index 46e8817485..09fa60953e 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py @@ -102,6 +102,24 @@ class TestStripToolReferenceBlocks: assert strip_tool_reference_blocks(42) == 42 assert strip_tool_reference_blocks(None) is None + def test_removes_dict_valued_tool_reference_child_entirely(self): + # Regression guard: when a tool_reference dict is assigned to + # a key rather than listed, the helper used to rewrite it to + # `null` (leaving the parent key with a None value). That is + # still schema-invalid upstream — remove the key entirely. + payload = { + "wrapper": {"type": "tool_reference", "tool_name": "find_block"}, + "keep": "value", + } + cleaned = strip_tool_reference_blocks(payload) + assert "wrapper" not in cleaned + assert cleaned["keep"] == "value" + + def test_preserves_genuine_none_values_on_non_dict_children(self): + payload = {"explicit_null": None, "text": "ok"} + cleaned = strip_tool_reference_blocks(payload) + assert cleaned == {"explicit_null": None, "text": "ok"} + # --------------------------------------------------------------------------- # strip_forbidden_betas_from_body @@ -250,9 +268,43 @@ class TestCleanRequestHeaders: def test_hop_by_hop_set_completeness(self): # Sanity check: if upstream removes hop-by-hop headers from # this set we want to know — keep the canonical RFC 7230 list. - for required in ("connection", "transfer-encoding", "host"): + for required in ( + "connection", + "transfer-encoding", + "host", + "trailer", + "trailers", + ): assert required in _HOP_BY_HOP_HEADERS + def test_drops_headers_listed_in_connection_field(self): + # Per RFC 7230 §6.1 intermediaries must also drop every + # header name listed in the incoming Connection field value + # (extension hop-by-hop headers signalled per-connection). + headers = { + "Connection": "X-Custom-Hop, Upgrade", + "X-Custom-Hop": "secret-extension", + "Authorization": "Bearer x", + "X-Keep": "ok", + } + cleaned = clean_request_headers(headers) + assert "X-Custom-Hop" not in cleaned + # Upgrade is a static hop-by-hop header; Connection itself is + # also dropped; the rest pass through. + assert "Connection" not in cleaned + assert cleaned["Authorization"] == "Bearer x" + assert cleaned["X-Keep"] == "ok" + + def test_connection_token_matching_is_case_insensitive(self): + headers = { + "Connection": "x-hop-HEADER", + "X-Hop-Header": "drop-me", + "X-Keep": "ok", + } + cleaned = clean_request_headers(headers) + assert "X-Hop-Header" not in cleaned + assert cleaned["X-Keep"] == "ok" + # --------------------------------------------------------------------------- # End-to-end: real proxy + fake upstream @@ -462,6 +514,68 @@ async def test_proxy_returns_502_on_upstream_failure(): await proxy.stop() +@pytest.mark.asyncio +async def test_proxy_returns_502_on_upstream_timeout(): + """``aiohttp.ClientTimeout`` raises ``asyncio.TimeoutError`` (not + ``aiohttp.ClientError``), which previously escaped the except + block and surfaced as a 500. This regression-guards the 502 + contract for hung upstreams.""" + + class _HangingUpstream: + """Upstream that accepts the request but never finishes the + response body, forcing the proxy's client timeout to fire.""" + + def __init__(self) -> None: + self._runner: web.AppRunner | None = None + self.port: int = 0 + + async def start(self) -> str: + async def handler(request: web.Request) -> web.StreamResponse: + # Hold the response open longer than the proxy's + # client timeout so aiohttp raises TimeoutError on + # the proxy side. + await asyncio.sleep(30) + return web.Response(status=200) + + app = web.Application() + app.router.add_route("*", "/{tail:.*}", handler) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + server = site._server + assert server is not None + sockets = getattr(server, "sockets", None) + assert sockets is not None + self.port = sockets[0].getsockname()[1] + return f"http://127.0.0.1:{self.port}" + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + upstream = _HangingUpstream() + upstream_url = await upstream.start() + # Short proxy timeout so the test finishes quickly. + proxy = OpenRouterCompatProxy(target_base_url=upstream_url, request_timeout=0.5) + await proxy.start() + try: + async with aiohttp.ClientSession() as client: + async with client.post( + f"{proxy.local_url}/v1/messages", + json={"model": "x"}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + assert resp.status == 502 + text = await resp.text() + # Generic error message — no internal hostname leaked. + assert "upstream error" in text + finally: + await proxy.stop() + await upstream.stop() + + @pytest.mark.asyncio async def test_proxy_local_url_raises_before_start(): proxy = OpenRouterCompatProxy(target_base_url="http://example.com") diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index d563462218..17972d4d88 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2258,35 +2258,63 @@ async def stream_chat_completion_sdk( # versions can talk to OpenRouter without their stricter # validation rejecting the request. if config.claude_agent_use_compat_proxy: - from backend.copilot.sdk.openrouter_compat_proxy import ( - OpenRouterCompatProxy, - ) + # Only start the compat proxy when there's already an + # explicit Anthropic-compatible upstream to forward to. + # Otherwise we'd be silently routing direct Anthropic / + # Claude Code subscription sessions through OpenRouter, + # which would break auth and change providers without + # operator consent. The explicit upstream can come from: + # + # 1. ``sdk_env['ANTHROPIC_BASE_URL']`` — caller override; + # 2. the process env — lowest-precedence host override; + # 3. ``ChatConfig.openrouter_active`` — OpenRouter is + # configured as the session's routing provider (i.e. + # the only case in which falling back to + # ``OPENROUTER_BASE_URL`` is intentional). + # + # When none of the above hold, log a warning and leave + # the CLI to talk to Anthropic directly as usual — the + # feature is opt-in and documented as "OpenRouter + # compatibility", so quietly no-oping on direct-Anthropic + # sessions is the safe default. + target_base_url: str | None = (sdk_env or {}).get( + "ANTHROPIC_BASE_URL" + ) or os.environ.get("ANTHROPIC_BASE_URL") + if not target_base_url and config.openrouter_active: + from backend.util.clients import OPENROUTER_BASE_URL - # Use the same upstream URL the SDK would have hit directly. - # Prefer an explicit override in ``sdk_env`` (e.g. set by a - # caller wanting to test against a specific gateway), then - # the parent process env, then the platform-wide - # ``OPENROUTER_BASE_URL`` constant. - from backend.util.clients import OPENROUTER_BASE_URL + target_base_url = OPENROUTER_BASE_URL - target_base_url = ( - (sdk_env or {}).get("ANTHROPIC_BASE_URL") - or os.environ.get("ANTHROPIC_BASE_URL") - or OPENROUTER_BASE_URL - ) - _compat_proxy = OpenRouterCompatProxy(target_base_url=target_base_url) - await _compat_proxy.start() - # Inject the proxy URL into the SDK env so the spawned CLI - # subprocess uses the proxy as its Anthropic endpoint. - if sdk_env is None: - sdk_env = {} - sdk_env["ANTHROPIC_BASE_URL"] = _compat_proxy.local_url - logger.info( - "%s OpenRouter compat proxy active: %s -> %s", - log_prefix, - _compat_proxy.local_url, - _compat_proxy.target_base_url, - ) + if target_base_url: + from backend.copilot.sdk.openrouter_compat_proxy import ( + OpenRouterCompatProxy, + ) + + _compat_proxy = OpenRouterCompatProxy(target_base_url=target_base_url) + await _compat_proxy.start() + # Inject the proxy URL into the SDK env so the spawned + # CLI subprocess uses the proxy as its Anthropic + # endpoint. + if sdk_env is None: + sdk_env = {} + sdk_env["ANTHROPIC_BASE_URL"] = _compat_proxy.local_url + # Log only the local bind URL — upstream is redacted + # to match the taint-analysis guidance applied in + # ``openrouter_compat_proxy.start``. + logger.info( + "%s OpenRouter compat proxy active (listening on %s)", + log_prefix, + _compat_proxy.local_url, + ) + else: + logger.warning( + "%s claude_agent_use_compat_proxy is enabled but no " + "Anthropic-compatible upstream is configured for this " + "session (no ANTHROPIC_BASE_URL override and " + "openrouter_active is False); skipping proxy startup " + "so the CLI keeps talking to Anthropic directly.", + log_prefix, + ) if sdk_env: sdk_options_kwargs["env"] = sdk_env From 5d6cf916426da1d07f180b443d4afb9164204cb3 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:26:17 +0000 Subject: [PATCH 09/34] fix(copilot): handle bool default in compat-proxy env validator The ``get_claude_agent_use_compat_proxy`` validator added in the previous commit used ``if v is None`` to decide when to fall back to the unprefixed env var. But unlike ``claude_agent_cli_path`` (which defaults to ``None``), this field has ``default=False``. Pydantic- settings passes the default bool into a ``mode="before"`` validator when no explicit value is provided, so the ``is None`` branch never fired and the unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` env var was silently ignored. Switch to checking the raw process env directly: if the prefixed ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` is set we trust Pydantic's parsed value (which preserves any explicit ``false``), otherwise we return the unprefixed env var's raw string so Pydantic's usual truthy/falsy coercion handles it. Added a new ``TestClaudeAgentUseCompatProxyEnvFallback`` class covering both env-var names, the prefixed-wins-over-unprefixed precedence (including the ``CHAT_...=false`` + unprefixed ``=true`` case), and the default. Also added the mirror tests for ``claude_agent_cli_path`` and included the new env var names in the ``_ENV_VARS_TO_CLEAR`` fixture so existing tests don't leak. Flagged by sentry review on #12745 (thread 3067888297). --- .../backend/backend/copilot/config.py | 17 +++- .../backend/backend/copilot/config_test.py | 88 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 0c67091a5a..099b3d0648 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -358,11 +358,20 @@ class ChatConfig(BaseSettings): usual truthy/falsy coercion (``"1"``, ``"true"``, ``"yes"``, ``"on"`` → True), so operators get the same behaviour they'd get from the prefixed env var. + + Note: unlike the ``claude_agent_cli_path`` case, this field has + a non-``None`` default (``False``), so Pydantic passes the + default bool into the validator when no value is set — a + simple ``if v is None`` check wouldn't fire. We instead inspect + the raw process env directly: if the prefixed var is set we + let Pydantic's value stand; otherwise the unprefixed var wins. """ - if v is None: - v = os.getenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY") - if v is None: - v = os.getenv("CLAUDE_AGENT_USE_COMPAT_PROXY") + if os.getenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY") is not None: + # Prefixed var is set — trust Pydantic's parsed value. + return v + unprefixed = os.getenv("CLAUDE_AGENT_USE_COMPAT_PROXY") + if unprefixed is not None: + return unprefixed return v # Prompt paths for different contexts diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index d63ce6bae1..ea6829227d 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -17,6 +17,10 @@ _ENV_VARS_TO_CLEAR = ( "CHAT_BASE_URL", "OPENROUTER_BASE_URL", "OPENAI_BASE_URL", + "CHAT_CLAUDE_AGENT_CLI_PATH", + "CLAUDE_AGENT_CLI_PATH", + "CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY", + "CLAUDE_AGENT_USE_COMPAT_PROXY", ) @@ -87,3 +91,87 @@ class TestE2BActive: """e2b_active is False when use_e2b_sandbox=False regardless of key.""" cfg = ChatConfig(use_e2b_sandbox=False, e2b_api_key="test-key") assert cfg.e2b_active is False + + +class TestClaudeAgentCliPathEnvFallback: + """``claude_agent_cli_path`` accepts both the Pydantic-prefixed + ``CHAT_CLAUDE_AGENT_CLI_PATH`` env var and the unprefixed + ``CLAUDE_AGENT_CLI_PATH`` form (mirrors ``api_key`` / ``base_url``). + """ + + def test_prefixed_env_var_is_picked_up( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", "/opt/claude-prefixed") + cfg = ChatConfig() + assert cfg.claude_agent_cli_path == "/opt/claude-prefixed" + + def test_unprefixed_env_var_is_picked_up( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", "/opt/claude-unprefixed") + cfg = ChatConfig() + assert cfg.claude_agent_cli_path == "/opt/claude-unprefixed" + + def test_prefixed_wins_over_unprefixed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", "/opt/claude-prefixed") + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", "/opt/claude-unprefixed") + cfg = ChatConfig() + assert cfg.claude_agent_cli_path == "/opt/claude-prefixed" + + def test_no_env_var_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = ChatConfig() + assert cfg.claude_agent_cli_path is None + + +class TestClaudeAgentUseCompatProxyEnvFallback: + """``claude_agent_use_compat_proxy`` accepts both the Pydantic- + prefixed ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` env var and the + unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` form. Regression + guard for the bool-default pitfall: the field has a non-None + default (``False``), so Pydantic passes the default into the + validator when no value is provided and a naive ``if v is None`` + check would never fire. + """ + + def test_prefixed_env_var_enables_proxy( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY", "true") + cfg = ChatConfig() + assert cfg.claude_agent_use_compat_proxy is True + + def test_unprefixed_env_var_enables_proxy( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "true") + cfg = ChatConfig() + assert cfg.claude_agent_use_compat_proxy is True + + def test_unprefixed_env_var_respects_falsy_value( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "false") + cfg = ChatConfig() + assert cfg.claude_agent_use_compat_proxy is False + + def test_prefixed_wins_over_unprefixed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When both are set, the Pydantic-prefixed var is authoritative + so the validator doesn't silently clobber an explicit + ``CHAT_...=false`` with an unprefixed ``=true``.""" + monkeypatch.setenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY", "false") + monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "true") + cfg = ChatConfig() + assert cfg.claude_agent_use_compat_proxy is False + + def test_no_env_var_uses_field_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + cfg = ChatConfig() + # Default is False on this branch; the dev-preview branch + # flips it to True but that's a separate PR. + assert cfg.claude_agent_use_compat_proxy is False From cd9924f03e8d42d2ac151c1dbf6164eeac274c23 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:30:31 +0000 Subject: [PATCH 10/34] fix(copilot/sdk-proxy): address CodeRabbit follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up findings from CodeRabbit's second-pass review: * The forbidden-pattern scanner in ``cli_openrouter_compat_test`` relied on a substring match against the prettified form `"type": "tool_reference"` (with a space). The CLI is free to emit compact JSON like `{"type":"tool_reference"}` which would slip past the scanner and false-pass the reproduction test. Replaced the substring check with a JSON walker that catches any dict with `type == "tool_reference"` regardless of serialisation, with a whitespace-tolerant regex fallback for malformed bodies. Added two regression tests (compact form, malformed fallback). * The timeout path in ``_run_cli_against_fake_server`` called ``proc.kill()`` and returned immediately, leaving an unreaped subprocess until event-loop shutdown. Reap it with a 5-second bounded ``proc.communicate()`` wait after the kill. * ``test_proxy_returns_502_on_upstream_failure`` swallowed ``aiohttp.ClientError`` / ``asyncio.TimeoutError`` on the outer ``client.post``. That outer call talks to the *proxy* on localhost — not the dead upstream — so any exception there indicates a proxy crash and must fail the test, not be caught. Removed the except block and bumped the client timeout to 10s to give the proxy room to return its 502. Also asserts the response body contains the generic "upstream error" text so a regression that replaces the 502 with a different status is caught. --- .../copilot/sdk/cli_openrouter_compat_test.py | 68 +++++++++++++++++-- .../sdk/openrouter_compat_proxy_test.py | 21 ++++-- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 56b8bc2dd6..045eee23f8 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -57,6 +57,7 @@ import asyncio import json import logging import os +import re import subprocess from pathlib import Path from typing import Any @@ -72,14 +73,42 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Substring of the `tool_reference` content block that breaks OpenRouter's -# stricter Zod validation in tool_result.content. PR #12294 root-cause. -_FORBIDDEN_TOOL_REFERENCE = '"type": "tool_reference"' - # Beta string OpenRouter rejects in upstream issue #789. Can appear in # either `betas` arrays or the `anthropic-beta` header value. _FORBIDDEN_CONTEXT_MANAGEMENT_BETA = "context-management-2025-06-27" +def _body_contains_tool_reference_block(body_text: str) -> bool: + """Return True if *body_text* contains a ``tool_reference`` content + block anywhere in its structure. + + We parse the JSON and walk it rather than relying on substring + matches because the CLI is free to emit either ``{"type": "tool_reference"}`` + (with spaces) or the compact ``{"type":"tool_reference"}`` form, + and we must catch both. Falls back to a whitespace-tolerant + regex when the body isn't valid JSON — the Messages API always + sends JSON, but the fallback keeps the detector honest on + malformed / partial bodies a fuzzer might produce. + """ + try: + payload = json.loads(body_text) + except (ValueError, TypeError): + # Whitespace-tolerant fallback: allow any whitespace between + # the key, colon, and value quoted string. + return bool(re.search(r'"type"\s*:\s*"tool_reference"', body_text)) + + def _walk(node: Any) -> bool: + if isinstance(node, dict): + if node.get("type") == "tool_reference": + return True + return any(_walk(v) for v in node.values()) + if isinstance(node, list): + return any(_walk(v) for v in node) + return False + + return _walk(payload) + + def _scan_request_for_forbidden_patterns( body_text: str, headers: dict[str, str], @@ -90,7 +119,7 @@ def _scan_request_for_forbidden_patterns( OpenRouter-incompatible features. """ findings: list[str] = [] - if _FORBIDDEN_TOOL_REFERENCE in body_text: + if _body_contains_tool_reference_block(body_text): findings.append( "`tool_reference` content block in request body — " "PR #12294 / CLI 2.1.69 regression" @@ -335,7 +364,15 @@ async def _run_cli_against_fake_server( proc.kill() except ProcessLookupError: pass - stdout_bytes, stderr_bytes = b"", b"" + # Reap the process after kill() so we don't leave an unreaped + # child behind until event-loop shutdown. Wait with its own + # short timeout in case the kill was ineffective. + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for( + proc.communicate(), timeout=5.0 + ) + except (asyncio.TimeoutError, TimeoutError): + stdout_bytes, stderr_bytes = b"", b"" return ( proc.returncode if proc.returncode is not None else -1, @@ -502,6 +539,27 @@ class TestScanRequestForForbiddenPatterns: assert "tool_reference" in findings[0] assert "context-management-2025-06-27" in findings[1] + def test_detects_compact_tool_reference_without_spaces(self): + # Regression guard: the old substring matcher only caught the + # prettified form '"type": "tool_reference"' with a space + # between the key and the value, so a CLI emitting compact + # JSON (e.g. via `json.dumps(separators=(",", ":"))`) could + # slip past the scanner and false-pass. The JSON-walking + # detector catches both forms. + body = '{"messages":[{"role":"user","content":[{"type":"tool_reference","tool_name":"find"}]}]}' + findings = _scan_request_for_forbidden_patterns(body, {}) + assert len(findings) == 1 + assert "tool_reference" in findings[0] + + def test_detects_tool_reference_in_malformed_body_fallback(self): + # When the body isn't valid JSON the helper falls back to a + # whitespace-tolerant regex so fuzzed / partial payloads are + # still caught. + body = 'garbage-prefix{"type" : "tool_reference"} trailing' + findings = _scan_request_for_forbidden_patterns(body, {}) + assert len(findings) == 1 + assert "tool_reference" in findings[0] + class TestResolveCliPath: def test_honours_explicit_env_var_when_file_exists(self, tmp_path, monkeypatch): diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py index 09fa60953e..cf1506a687 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py @@ -492,7 +492,16 @@ async def test_proxy_passes_through_clean_request_unchanged(): @pytest.mark.asyncio async def test_proxy_returns_502_on_upstream_failure(): """If the upstream is unreachable the proxy must return a clear - 502, not silently hang.""" + 502, not silently hang. + + Note: the outer ``client.post`` talks to the *proxy* on localhost, + not to the dead upstream directly. The proxy is the thing under + test, so it should always respond with a 502 — we must NOT + swallow ``aiohttp.ClientError`` / ``asyncio.TimeoutError`` on the + outer call, because that would mask a proxy crash and turn the + assertion into a false positive. Let any such exception fail the + test. + """ proxy = OpenRouterCompatProxy( target_base_url="http://127.0.0.1:1", # nothing listening ) @@ -502,14 +511,12 @@ async def test_proxy_returns_502_on_upstream_failure(): async with client.post( f"{proxy.local_url}/v1/messages", json={"model": "x"}, - timeout=aiohttp.ClientTimeout(total=5), + timeout=aiohttp.ClientTimeout(total=10), ) as resp: assert resp.status == 502 - except (aiohttp.ClientError, asyncio.TimeoutError): - # Some platforms refuse the connection so quickly aiohttp - # raises before the proxy can respond — that also satisfies - # the spirit of the test (no infinite hang). - pass + text = await resp.text() + # Generic error message — no internal hostname leaked. + assert "upstream error" in text finally: await proxy.stop() From 370499c8dc0fc139af5848f4dd8798c9c03f1581 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:41:40 +0000 Subject: [PATCH 11/34] fix(copilot/sdk-proxy): don't signal clean EOF on mid-stream error When an ``aiohttp.ClientError`` fires mid-stream the previous code logged it and then called ``downstream.write_eof()``, which tells the downstream client "stream complete" on top of a partial, truncated body. Clients then silently consumed the corrupt response as if it were a clean success. Track the stream error in a local variable and, when it's set, skip the ``write_eof`` call and ``force_close`` the downstream response so aiohttp drops the connection mid-body. The client's parser then raises a ``ClientPayloadError`` / ``ServerDisconnectedError`` and the failure is surfaced instead of silently producing garbage. Added a regression test that spins up an upstream which calls ``force_close`` mid-response; the proxy must propagate the failure to the client (exception on ``resp.read()``), never return a clean body. Flagged by sentry review on #12745 (thread 3067897364). --- .../copilot/sdk/openrouter_compat_proxy.py | 24 +++++ .../sdk/openrouter_compat_proxy_test.py | 90 +++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index cc654a1396..2c03d94ae1 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -439,7 +439,15 @@ class OpenRouterCompatProxy: headers=clean_request_headers(dict(upstream_response.headers)), ) await downstream.prepare(request) + # Track whether the stream terminated cleanly. A mid-stream + # ``aiohttp.ClientError`` means the upstream died before + # finishing; calling ``write_eof()`` on that partial response + # would signal "complete stream" to the downstream client and + # silently corrupt the body. Skip the EOF on the error path + # so the client's connection is dropped instead, surfacing the + # failure correctly. cancelled = False + stream_error: aiohttp.ClientError | None = None try: async for chunk in upstream_response.content.iter_any(): await downstream.write(chunk) @@ -454,9 +462,25 @@ class OpenRouterCompatProxy: upstream_response.release() raise except aiohttp.ClientError as e: + stream_error = e logger.warning("OpenRouter compat proxy stream interrupted: %s", e) finally: if not cancelled: upstream_response.release() + + if stream_error is not None: + # Do NOT call ``write_eof`` — that would signal a clean end + # of stream to the client on top of a truncated body. + # Mark the connection for close (``Connection: close``) and + # skip the EOF so the aiohttp writer drops the connection + # mid-response. The client's parser then raises a + # transport error and the caller can retry / surface the + # failure instead of silently consuming a corrupt body. + try: + downstream.force_close() + except Exception: # pragma: no cover - defensive on transport + pass + return downstream + await downstream.write_eof() return downstream diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py index cf1506a687..5f408d16e6 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py @@ -583,6 +583,96 @@ async def test_proxy_returns_502_on_upstream_timeout(): await upstream.stop() +@pytest.mark.asyncio +async def test_proxy_does_not_signal_clean_eof_on_mid_stream_error(): + """Regression guard: if the upstream stream dies mid-body, the + proxy must NOT call ``write_eof()`` — that would mark the + downstream response as a complete, valid stream even though the + client only saw a truncated body. Instead the proxy drops the + connection so the client's parser surfaces a transport error. + + We simulate the failure by giving the proxy an upstream that + closes the TCP socket mid-response. The proxy must either drop + the client connection (``aiohttp.ClientPayloadError`` / + ``ClientConnectionError``) or — if aiohttp masks it — at least + not report an ``ok`` complete body. + """ + + class _TruncatingUpstream: + """Upstream that starts sending a response then kills the + connection before ``write_eof`` — mimicking a backend that + dies mid-stream.""" + + def __init__(self) -> None: + self._runner: web.AppRunner | None = None + self.port: int = 0 + + async def start(self) -> str: + async def handler(request: web.Request) -> web.StreamResponse: + resp = web.StreamResponse( + status=200, + headers={"Content-Type": "application/octet-stream"}, + ) + await resp.prepare(request) + await resp.write(b"partial-") + # Force-close without write_eof so the proxy's + # iter_any() raises mid-stream. + resp.force_close() + return resp + + app = web.Application() + app.router.add_route("*", "/{tail:.*}", handler) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + server = site._server + assert server is not None + sockets = getattr(server, "sockets", None) + assert sockets is not None + self.port = sockets[0].getsockname()[1] + return f"http://127.0.0.1:{self.port}" + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + upstream = _TruncatingUpstream() + upstream_url = await upstream.start() + proxy = OpenRouterCompatProxy(target_base_url=upstream_url) + await proxy.start() + try: + async with aiohttp.ClientSession() as client: + client_error: Exception | None = None + try: + async with client.post( + f"{proxy.local_url}/v1/messages", + json={"model": "x"}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + # The client should see either an error raising + # here or a truncated body followed by a + # transport-level failure on read — both are + # acceptable because both surface the truncation + # instead of silently reporting success. + await resp.read() + except ( + aiohttp.ClientPayloadError, + aiohttp.ClientConnectionError, + aiohttp.ServerDisconnectedError, + ) as e: + client_error = e + assert client_error is not None, ( + "Proxy silently consumed an upstream mid-stream " + "failure and returned a clean EOF to the client — " + "regression in the stream-error path." + ) + finally: + await proxy.stop() + await upstream.stop() + + @pytest.mark.asyncio async def test_proxy_local_url_raises_before_start(): proxy = OpenRouterCompatProxy(target_base_url="http://example.com") From 8742c5e5b9bc352409ae92dd725971a349178308 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:48:04 +0000 Subject: [PATCH 12/34] fix(copilot/sdk-proxy): treat empty sdk_env ANTHROPIC_BASE_URL as opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code subscription mode intentionally sets ``sdk_env['ANTHROPIC_BASE_URL'] = ""`` to disable any base-URL override and keep the CLI talking to Anthropic directly. The previous ``or``-chained lookup evaluated the empty string as falsy and fell through to ``os.environ.get("ANTHROPIC_BASE_URL")`` and then to ``OPENROUTER_BASE_URL``, silently starting the compat proxy for a session that had explicitly opted out — which breaks subscription auth. Use a presence check on ``sdk_env`` instead: if the key is present with an empty value it's a hard "no-proxy" signal, so skip the OpenRouter fallback even when ``openrouter_active`` is True. The process-env fallback and the OpenRouter fallback still cover the original cases (no sdk_env override, OpenRouter is the routing provider for this session). Flagged by sentry review on #12745 (thread 3067906804). --- .../backend/backend/copilot/sdk/service.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 17972d4d88..96dfb67e8e 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2277,10 +2277,34 @@ async def stream_chat_completion_sdk( # feature is opt-in and documented as "OpenRouter # compatibility", so quietly no-oping on direct-Anthropic # sessions is the safe default. - target_base_url: str | None = (sdk_env or {}).get( - "ANTHROPIC_BASE_URL" - ) or os.environ.get("ANTHROPIC_BASE_URL") - if not target_base_url and config.openrouter_active: + # Claude Code subscription mode intentionally sets + # ``sdk_env['ANTHROPIC_BASE_URL'] = ""`` to *disable* any + # base-URL override and keep the CLI talking to Anthropic + # directly. Treat an explicit empty string as a hard + # "no-proxy" signal so we never silently start the proxy + # against a host-wide ``ANTHROPIC_BASE_URL`` or fall back + # to OpenRouter when the caller has opted out. + sdk_env_map = sdk_env or {} + explicit_sdk_env = "ANTHROPIC_BASE_URL" in sdk_env_map + sdk_env_value = ( + sdk_env_map["ANTHROPIC_BASE_URL"] if explicit_sdk_env else None + ) + if explicit_sdk_env and not sdk_env_value: + # Empty string from sdk_env → subscription mode opt-out. + target_base_url: str | None = None + explicit_opt_out = True + else: + target_base_url = sdk_env_value or os.environ.get("ANTHROPIC_BASE_URL") + explicit_opt_out = False + # Only fall back to OpenRouter when the session actually + # has no base-URL plumbing of its own AND OpenRouter is + # the active routing provider AND the caller hasn't + # explicitly opted out via an empty sdk_env override. + if ( + not target_base_url + and not explicit_opt_out + and config.openrouter_active + ): from backend.util.clients import OPENROUTER_BASE_URL target_base_url = OPENROUTER_BASE_URL From 428ed39a1adf1bc0f0911bfd2d44379baf5ca580 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 11:04:50 +0000 Subject: [PATCH 13/34] fix(copilot/sdk-proxy): abort transport on mid-stream upstream error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix set a ``stream_error`` flag and returned the prepared ``StreamResponse`` without calling ``write_eof()``, assuming aiohttp would leave the body dangling. It doesn't: aiohttp's handler dispatcher finalises any returned ``StreamResponse`` on the way out (writing the chunked terminator / content-length / EOF), so a regression test with a real mid-stream failure still saw the client get a clean 200 body. Correct fix: on the stream-error path, abort the underlying transport directly via ``request.transport.abort()`` and then re-raise the original stream error out of the handler. Aborting drops the TCP socket mid-response so the client's parser surfaces a ``ClientPayloadError`` / ``ServerDisconnectedError`` and the caller sees the truncation as a real transport failure. Also rewrote the regression test to use a raw ``asyncio.start_server`` TCP handler that sends a chunked response header plus one partial chunk and then hard-closes the socket (``transport.abort()``) — this is the one failure mode that reliably propagates through aiohttp's ``iter_any()`` as a ``ClientError`` for the proxy to detect. Verified locally: the test now fails with the expected ``ClientPayloadError`` on the client side instead of silently returning 200. --- .../copilot/sdk/openrouter_compat_proxy.py | 30 +++++-- .../sdk/openrouter_compat_proxy_test.py | 87 +++++++++++-------- 2 files changed, 73 insertions(+), 44 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index 2c03d94ae1..dc1b9adbd8 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -469,18 +469,32 @@ class OpenRouterCompatProxy: upstream_response.release() if stream_error is not None: - # Do NOT call ``write_eof`` — that would signal a clean end - # of stream to the client on top of a truncated body. - # Mark the connection for close (``Connection: close``) and - # skip the EOF so the aiohttp writer drops the connection - # mid-response. The client's parser then raises a - # transport error and the caller can retry / surface the - # failure instead of silently consuming a corrupt body. + # Do NOT call ``write_eof`` or return the prepared + # ``downstream`` here — aiohttp finalises a returned + # StreamResponse (writing the terminating chunk / + # content-length / EOF) even if we skipped ``write_eof`` + # ourselves, which would signal a clean end of stream to + # the client on top of the truncated body. Instead abort + # the underlying transport directly so the client's + # parser surfaces a ``ClientPayloadError`` / + # ``ServerDisconnectedError`` and the caller can retry / + # surface the failure instead of silently consuming a + # corrupt body. try: downstream.force_close() except Exception: # pragma: no cover - defensive on transport pass - return downstream + transport = request.transport + if transport is not None: + try: + transport.abort() + except Exception: # pragma: no cover - defensive on transport + pass + # Re-raise the original stream error so aiohttp treats + # this handler as having failed; the transport is + # already aborted above so the client sees an abrupt + # disconnect either way. + raise stream_error await downstream.write_eof() return downstream diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py index 5f408d16e6..c98711e24f 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py @@ -591,56 +591,71 @@ async def test_proxy_does_not_signal_clean_eof_on_mid_stream_error(): client only saw a truncated body. Instead the proxy drops the connection so the client's parser surfaces a transport error. - We simulate the failure by giving the proxy an upstream that - closes the TCP socket mid-response. The proxy must either drop - the client connection (``aiohttp.ClientPayloadError`` / - ``ClientConnectionError``) or — if aiohttp masks it — at least - not report an ``ok`` complete body. + We simulate the failure with a raw asyncio TCP server that + sends a chunked-encoding response header plus one partial chunk + and then hard-closes the socket — this is the one failure mode + aiohttp's ``iter_any()`` reliably surfaces as an + ``aiohttp.ClientError`` rather than an ordinary clean EOF. """ class _TruncatingUpstream: - """Upstream that starts sending a response then kills the - connection before ``write_eof`` — mimicking a backend that - dies mid-stream.""" + """Raw TCP server that sends a partial chunked body then + closes the socket without writing the terminating chunk.""" def __init__(self) -> None: - self._runner: web.AppRunner | None = None + self._server: asyncio.base_events.Server | None = None self.port: int = 0 async def start(self) -> str: - async def handler(request: web.Request) -> web.StreamResponse: - resp = web.StreamResponse( - status=200, - headers={"Content-Type": "application/octet-stream"}, - ) - await resp.prepare(request) - await resp.write(b"partial-") - # Force-close without write_eof so the proxy's - # iter_any() raises mid-stream. - resp.force_close() - return resp + async def handle_conn( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + # Read and discard the request until the blank + # line — we don't care what the proxy sends. + while True: + line = await reader.readline() + if not line or line == b"\r\n": + break + # Chunked response with one partial chunk. + writer.write( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/octet-stream\r\n" + b"Transfer-Encoding: chunked\r\n" + b"Connection: close\r\n" + b"\r\n" + # One chunk, size 8, content "partial-". + b"8\r\n" + b"partial-\r\n" + # Deliberately DO NOT send the terminating + # "0\r\n\r\n" — this is the mid-stream + # truncation we're testing. + ) + await writer.drain() + finally: + # Hard-close the socket so the proxy's + # iter_any() sees an abrupt end-of-stream. + try: + writer.transport.abort() + except Exception: + pass - app = web.Application() - app.router.add_route("*", "/{tail:.*}", handler) - self._runner = web.AppRunner(app) - await self._runner.setup() - site = web.TCPSite(self._runner, "127.0.0.1", 0) - await site.start() - server = site._server - assert server is not None - sockets = getattr(server, "sockets", None) + self._server = await asyncio.start_server(handle_conn, "127.0.0.1", 0) + sockets = self._server.sockets assert sockets is not None self.port = sockets[0].getsockname()[1] return f"http://127.0.0.1:{self.port}" async def stop(self) -> None: - if self._runner is not None: - await self._runner.cleanup() - self._runner = None + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None upstream = _TruncatingUpstream() upstream_url = await upstream.start() - proxy = OpenRouterCompatProxy(target_base_url=upstream_url) + proxy = OpenRouterCompatProxy(target_base_url=upstream_url, request_timeout=5.0) await proxy.start() try: async with aiohttp.ClientSession() as client: @@ -653,9 +668,9 @@ async def test_proxy_does_not_signal_clean_eof_on_mid_stream_error(): ) as resp: # The client should see either an error raising # here or a truncated body followed by a - # transport-level failure on read — both are - # acceptable because both surface the truncation - # instead of silently reporting success. + # transport-level failure on read — both surface + # the truncation instead of silently reporting + # success. await resp.read() except ( aiohttp.ClientPayloadError, From 5cf60587ef0651381cb0bcc30fc7c769f01a402b Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 07:59:49 +0000 Subject: [PATCH 14/34] chore(deps): bump claude-agent-sdk to 0.1.58 with compat proxy enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev preview PR — combines the cli_path plumbing (#12741), the in-process compat proxy (#12745), and the SDK bump in one branch so we can dogfood the full upgrade end-to-end. Changes: * `claude-agent-sdk` -> 0.1.58 (bundled CLI 2.1.97). Gets us all the blocked features: - `exclude_dynamic_sections` cross-user prompt cache hits (0.1.57) — directly amplifies #12725 - `AssistantMessage.usage` per-turn token tracking (0.1.49) — cost attribution - `task_budget` (0.1.51) — per-task cost ceiling - `get_context_usage()` (0.1.52) — context window monitoring - MCP large-tool-result truncation fix (0.1.55) - MCP HTTP/SSE buffer leak fix (CLI 2.1.97) — known production memory creep - 429 retry exponential-backoff fix (CLI 2.1.97) — production rate-limit recovery - `--resume` cache miss regression fix (CLI 2.1.90) - SDK session quadratic-write fix (CLI 2.1.90) * `ChatConfig.claude_agent_use_compat_proxy` default flipped from `False` -> `True`. The bundled CLI in 0.1.55+ injects the `context-management-2025-06-27` beta header which OpenRouter rejects (anthropics/claude-agent-sdk-python#789). The proxy strips it transparently. Disable explicitly only if you've pinned to a CLI version in `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT`. * `sdk_compat_test.py` pin assertion split into two known-good sets: - `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT` — works without the proxy ({"2.1.63", "2.1.70"}) - `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY` — works only with the compat proxy enabled ({"2.1.97"}) The test now requires `claude_agent_use_compat_proxy=True` for proxy-only versions, so disabling the proxy on a fresh checkout with this PR fails fast with a clear error. Operational rollout (when ready to ship beyond dev preview): 1. Merge #12741 (plumbing + reproduction test) 2. Merge #12745 (proxy module — opt-in default off) 3. Merge this PR (bumps SDK + flips default to on) 4. Watch production for the existing reproduction test running continuously as a regression guard 5. If anything goes wrong: revert this PR (proxy becomes opt-in again, SDK back to whichever version is in the previous merge) Dev preview usage: deploy this branch with no env-var changes — the proxy is on by default. The reproduction test will continue to pass against the bundled CLI 2.1.97 when (and only when) the proxy successfully strips the forbidden patterns. --- .../backend/backend/copilot/config.py | 25 +++--- .../backend/copilot/sdk/sdk_compat_test.py | 77 ++++++++++++++++--- autogpt_platform/backend/poetry.lock | 17 ++-- autogpt_platform/backend/pyproject.toml | 2 +- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 099b3d0648..949f010701 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -187,21 +187,26 @@ class ChatConfig(BaseSettings): "(same pattern as `api_key` / `base_url`).", ) claude_agent_use_compat_proxy: bool = Field( - default=False, + default=True, description="Run the in-process OpenRouter compatibility proxy " "(`backend.copilot.sdk.openrouter_compat_proxy`) in front of the " "Claude Code CLI. The proxy strips `tool_reference` content " "blocks and the `context-management-2025-06-27` beta header / " "field from outgoing requests so newer SDK / CLI versions stop " - "tripping OpenRouter's stricter validation. Orthogonal to " - "`claude_agent_cli_path` — the override picks the binary, the " - "proxy rewrites whatever the binary sends. Reads from " - "`CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY` or the unprefixed " - "`CLAUDE_AGENT_USE_COMPAT_PROXY` environment variable (same " - "pattern as `claude_agent_cli_path`). Only takes effect when " - "the session has an Anthropic-compatible upstream to forward " - "to — direct-Anthropic sessions skip the proxy entirely to " - "avoid silently re-routing through OpenRouter.", + "tripping OpenRouter's stricter validation. Defaults to True " + "because the bundled CLI in `claude-agent-sdk >= 0.1.55` requires " + "the proxy. Orthogonal to `claude_agent_cli_path` — the override " + "picks the binary, the proxy rewrites whatever the binary sends. " + "Disable explicitly only if you've pinned `claude-agent-sdk` to " + "a version whose bundled CLI is in " + "`_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT` (2.1.63 or 2.1.70). " + "Reads from `CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY` or the " + "unprefixed `CLAUDE_AGENT_USE_COMPAT_PROXY` environment " + "variable (same pattern as `claude_agent_cli_path`). Only " + "takes effect when the session has an Anthropic-compatible " + "upstream to forward to — direct-Anthropic sessions skip the " + "proxy entirely to avoid silently re-routing through " + "OpenRouter.", ) use_openrouter: bool = Field( default=True, diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index 0d949b93fa..835bd82603 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -232,27 +232,82 @@ def test_sdk_exports_hook_event_type(hook_event: str): # version, so the SDK Python API surface and the CLI binary version can # be picked independently. -# CLI versions verified to work against OpenRouter from production -# traffic. When upstream lands a fix and we can confirm a newer version -# works, add it to this set rather than blanket-removing the assertion. -_KNOWN_GOOD_BUNDLED_CLI_VERSIONS: frozenset[str] = frozenset({"2.1.63"}) +# CLI versions verified to work against OpenRouter directly (no compat +# proxy required) — bisected via the reproduction test in +# `cli_openrouter_compat_test.py`. Bundled CLI versions outside this +# set are still allowed but ONLY when the compat proxy is enabled (see +# the second known-good set below + the test below). +_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT: frozenset[str] = frozenset( + { + "2.1.63", # claude-agent-sdk 0.1.45 — original pin from PR #12294. + "2.1.70", # claude-agent-sdk 0.1.47 — first version with the + # tool_reference proxy detection fix; bisect-verified + # OpenRouter-safe in #12742. + } +) + +# CLI versions verified to work against OpenRouter ONLY when the +# in-process `openrouter_compat_proxy` is enabled (which strips the +# `tool_reference` content blocks and `context-management-2025-06-27` +# beta from outgoing requests). Without the proxy these CLI versions +# trip OpenRouter's stricter validation and return 400. +_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY: frozenset[str] = frozenset( + { + "2.1.97", # claude-agent-sdk 0.1.58 — needs `claude_agent_use_compat_proxy=True` + # due to the upstream regression in + # anthropics/claude-agent-sdk-python#789. + } +) + +# Aggregate set used by the assertion below — the test allows EITHER +# a directly-known-good CLI OR a proxy-known-good CLI when the proxy +# is enabled in the active config. +_KNOWN_GOOD_BUNDLED_CLI_VERSIONS: frozenset[str] = ( + _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT | _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY +) def test_bundled_cli_version_is_known_good_against_openrouter(): """Pin the bundled CLI version so accidental SDK bumps cause a loud, - fast failure with a pointer to the OpenRouter compatibility issue.""" + fast failure with a pointer to the OpenRouter compatibility issue. + + A CLI version that's only safe via the compat proxy is allowed only + when ``ChatConfig.claude_agent_use_compat_proxy`` is enabled. + """ from claude_agent_sdk._cli_version import __cli_version__ - assert __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS, ( + from backend.copilot.config import ChatConfig + + cfg = ChatConfig() + proxy_enabled = cfg.claude_agent_use_compat_proxy + + if __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT: + return # safe with or without the proxy + + if __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY: + assert proxy_enabled, ( + f"Bundled Claude Code CLI version {__cli_version__!r} is only " + "OpenRouter-safe when `claude_agent_use_compat_proxy` is " + "enabled, but the active ChatConfig has the proxy disabled. " + "Either set `COPILOT__CLAUDE_AGENT_USE_COMPAT_PROXY=true` or " + "downgrade `claude-agent-sdk` to a version whose bundled CLI " + f"is in {sorted(_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT)!r}. " + "See https://github.com/anthropics/claude-agent-sdk-python/issues/789." + ) + return + + raise AssertionError( f"Bundled Claude Code CLI version is {__cli_version__!r}, which is " - f"not in the OpenRouter-known-good set " - f"{sorted(_KNOWN_GOOD_BUNDLED_CLI_VERSIONS)!r}. " + f"not in any OpenRouter-known-good set " + f"({sorted(_KNOWN_GOOD_BUNDLED_CLI_VERSIONS)!r}). " "If you intentionally bumped `claude-agent-sdk`, verify the new " "bundled CLI works with OpenRouter against the reproduction test " "in `cli_openrouter_compat_test.py`, then add the new CLI version " - "to `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS`. If you cannot make the " - "bundled CLI work, set `claude_agent_cli_path` to a known-good " - "binary instead and skip the bundled one. See " + "to either `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT` (works " + "without the proxy) or `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY` " + "(works only with `claude_agent_use_compat_proxy=true`). If you " + "cannot make the bundled CLI work either way, set " + "`claude_agent_cli_path` to a known-good binary instead. See " "https://github.com/anthropics/claude-agent-sdk-python/issues/789 " "and https://github.com/Significant-Gravitas/AutoGPT/pull/12294." ) diff --git a/autogpt_platform/backend/poetry.lock b/autogpt_platform/backend/poetry.lock index f82230d91f..03c93c286a 100644 --- a/autogpt_platform/backend/poetry.lock +++ b/autogpt_platform/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "agentmail" @@ -909,17 +909,18 @@ files = [ [[package]] name = "claude-agent-sdk" -version = "0.1.45" +version = "0.1.58" description = "Python SDK for Claude Code" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "claude_agent_sdk-0.1.45-py3-none-macosx_11_0_arm64.whl", hash = "sha256:26a5cc60c3a394f5b814f6b2f67650819cbcd38c405bbdc11582b3e097b3a770"}, - {file = "claude_agent_sdk-0.1.45-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:decc741b53e0b2c10a64fd84c15acca1102077d9f99941c54905172cd95160c9"}, - {file = "claude_agent_sdk-0.1.45-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:7d48dcf4178c704e4ccbf3f1f4ebf20b3de3f03d0592086c1f3abd16b8ca441e"}, - {file = "claude_agent_sdk-0.1.45-py3-none-win_amd64.whl", hash = "sha256:d1cf34995109c513d8daabcae7208edc260b553b53462a9ac06a7c40e240a288"}, - {file = "claude_agent_sdk-0.1.45.tar.gz", hash = "sha256:97c1e981431b5af1e08c34731906ab8d4a58fe0774a04df0ea9587dcabc85151"}, + {file = "claude_agent_sdk-0.1.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:69197950809754c4f06bba8261f2d99c3f9605b6cc1c13d3409d0eb82fb4ee64"}, + {file = "claude_agent_sdk-0.1.58-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:75d60883fc5e2070bccd8d9b19505fe16af8e049120c03821e9dc8c826cca434"}, + {file = "claude_agent_sdk-0.1.58-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:7bf4eb0f00ec944a7b63eb94788f120dfb0460c348a525235c7d6641805acc1d"}, + {file = "claude_agent_sdk-0.1.58-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:650d298a3d3c0dcdde4b5f1dbf52f472ff0b0ec82987b27ffa2a4e0e72928408"}, + {file = "claude_agent_sdk-0.1.58-py3-none-win_amd64.whl", hash = "sha256:2c2130a7ffe06ed4f88d56b217a5091c91c9bcb1a69cfd94d5dcf0d2946d8c55"}, + {file = "claude_agent_sdk-0.1.58.tar.gz", hash = "sha256:77bee8fd60be033cb870def46c2ab1625a512fa8a3de4ff8d766664ffb16d6a6"}, ] [package.dependencies] @@ -8928,4 +8929,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "da61798b73758b9292fc1933268d488fbe739dc1fbf5c6586cd0c76a3411eb2e" +content-hash = "c4cc6a0a26869a167ce182b178224554135d89d8ffa4605257d17b3f495cdf59" diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml index ba82ecdd3c..08b1d5f1bc 100644 --- a/autogpt_platform/backend/pyproject.toml +++ b/autogpt_platform/backend/pyproject.toml @@ -18,7 +18,7 @@ apscheduler = "^3.11.1" autogpt-libs = { path = "../autogpt_libs", develop = true } bleach = { extras = ["css"], version = "^6.2.0" } cachetools = "^5.5.0" -claude-agent-sdk = "0.1.45" # see copilot/sdk/sdk_compat_test.py for capability checks +claude-agent-sdk = "0.1.58" # latest stable; bundled CLI 2.1.97 ships the broken context-management beta and REQUIRES the openrouter_compat_proxy. See sdk_compat_test.py. click = "^8.2.0" cryptography = "^46.0" discord-py = "^2.5.2" From 2af87616def7e0a09db5b8e22d9a8362c1683112 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 08:02:27 +0000 Subject: [PATCH 15/34] test(copilot/sdk-compat): add proxy-routed reproduction variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `test_cli_via_compat_proxy_emits_clean_requests_to_upstream` so the compat proxy has a real end-to-end regression guard: spawn the bundled CLI through the proxy against a fake upstream, capture what the upstream sees, assert it's clean. The bare reproduction test (`test_cli_does_not_send_openrouter_incompatible_features`) keeps its original semantics — proves the bundled CLI is or isn't broken upstream — so we still get a clean bisect signal when changing the SDK pin. Together the two tests give: * bare CLI clean → bare test passes; proxy test passes (no-op). * bare CLI broken → bare test fails (intentional bisect signal); proxy test passes if and only if the proxy successfully strips the forbidden patterns. Which means on this dev preview branch (SDK 0.1.58 with proxy on), CI catches both: * the regression actually exists (bare test fails — that's the reproduction the user asked for), and * the proxy actually fixes it (proxy test passes — that's the workaround validation). --- .../copilot/sdk/cli_openrouter_compat_test.py | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 045eee23f8..f66dad0800 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -386,21 +386,17 @@ async def _run_cli_against_fake_server( # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_cli_does_not_send_openrouter_incompatible_features(caplog): - """End-to-end OpenRouter compatibility reproduction. +async def _run_reproduction( + *, route_through_proxy: bool +) -> tuple[int, str, str, list[_CapturedRequest]]: + """Spawn the CLI against a fake Anthropic API and return what the + *upstream* (post-proxy if any) saw. - Spawns the bundled (or overridden) Claude Code CLI against a fake - Anthropic API server, captures every request body it sends, and - asserts that none of them contain the two known OpenRouter-breaking - features (`tool_reference` content blocks or the - `context-management-2025-06-27` beta header). - - Why this matters: pinning the CLI version via - ``test_bundled_cli_version_is_known_good_against_openrouter`` only - catches accidental SDK bumps — it doesn't tell us *why* the new - version would fail. This test reproduces the exact mechanism so - bisecting via CI commits gives an actionable signal. + When ``route_through_proxy`` is True, the CLI talks to the + ``OpenRouterCompatProxy`` and the proxy forwards to the fake + upstream. The fake upstream is what records the requests, so the + captured bodies are what OpenRouter would actually have received — + *after* the proxy's stripping pass. """ cli_path = _resolve_cli_path() if cli_path is None or not cli_path.is_file(): @@ -411,28 +407,39 @@ async def test_cli_does_not_send_openrouter_incompatible_features(caplog): ) captured: list[_CapturedRequest] = [] - runner, port = await _start_fake_anthropic_server(captured) + upstream_runner, upstream_port = await _start_fake_anthropic_server(captured) + + proxy = None + target_port = upstream_port try: + if route_through_proxy: + from backend.copilot.sdk.openrouter_compat_proxy import ( + OpenRouterCompatProxy, + ) + + proxy = OpenRouterCompatProxy( + target_base_url=f"http://127.0.0.1:{upstream_port}" + ) + await proxy.start() + # Pull the bound port out of the proxy URL. + target_port = int(proxy.local_url.rsplit(":", 1)[1]) + returncode, stdout, stderr = await _run_cli_against_fake_server( cli_path=cli_path, - fake_server_port=port, + fake_server_port=target_port, timeout_seconds=30.0, ) finally: - await runner.cleanup() + if proxy is not None: + await proxy.stop() + await upstream_runner.cleanup() - # We don't assert the CLI's exit code — depending on the CLI version - # and what we send back, the CLI may exit non-zero after a single - # successful round-trip. All we care about is that the captured - # request bodies don't contain the forbidden patterns. - logger.info( - "CLI exited rc=%d; captured %d requests; stdout=%d bytes; stderr=%d bytes", - returncode, - len(captured), - len(stdout), - len(stderr), - ) + return returncode, stdout, stderr, captured + +def _assert_no_forbidden_patterns( + captured: list[_CapturedRequest], returncode: int, stderr: str +) -> None: if not captured: pytest.skip( "Bundled CLI did not make any HTTP requests to the fake server " @@ -456,12 +463,57 @@ async def test_cli_does_not_send_openrouter_incompatible_features(caplog): "`claude-agent-sdk` above 0.1.45. See " "https://github.com/Significant-Gravitas/AutoGPT/pull/12294 and " "https://github.com/anthropics/claude-agent-sdk-python/issues/789. " - "If you intended to upgrade, you must use a known-good CLI binary " - "via `claude_agent_cli_path` (env: `CLAUDE_AGENT_CLI_PATH` or " - "`CHAT_CLAUDE_AGENT_CLI_PATH`) instead of the bundled one." + "If you intended to upgrade, you must enable the in-process compat " + "proxy (`CLAUDE_AGENT_USE_COMPAT_PROXY=true` or the prefixed " + "`CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY=true`) or use a known-good " + "CLI binary via `claude_agent_cli_path` (env: " + "`CLAUDE_AGENT_CLI_PATH` or `CHAT_CLAUDE_AGENT_CLI_PATH`)." ) +@pytest.mark.asyncio +async def test_cli_does_not_send_openrouter_incompatible_features(): + """End-to-end OpenRouter compatibility reproduction (bare CLI path). + + Spawns the bundled (or overridden) Claude Code CLI against a fake + Anthropic API server WITHOUT the compat proxy in the loop, captures + every request body it sends, and asserts that none of them contain + the two known OpenRouter-breaking features. + + On a clean SDK pin (0.1.45 or 0.1.47, bundled CLI 2.1.63 or 2.1.70) + this passes naturally. On a broken pin (0.1.55+, bundled CLI 2.1.91+) + it fails — that failure IS the bisect signal we use to verify which + SDK versions need the workaround. + """ + returncode, _stdout, stderr, captured = await _run_reproduction( + route_through_proxy=False + ) + _assert_no_forbidden_patterns(captured, returncode, stderr) + + +@pytest.mark.asyncio +async def test_cli_via_compat_proxy_emits_clean_requests_to_upstream(): + """End-to-end test for the compat proxy workaround. + + Spawns the bundled CLI against an in-process fake Anthropic API + server WITH the ``OpenRouterCompatProxy`` in front, then asserts + that the *upstream* sees clean requests — no `tool_reference` + blocks, no `context-management-2025-06-27` beta header — even + when the bundled CLI itself would have sent them. + + This is the regression guard for the proxy: if the proxy ever + stops stripping a known forbidden pattern, this test catches it. + On a SDK version where the bare CLI is already clean (0.1.45 / + 0.1.47), the proxy is a no-op and the test passes trivially. + On a SDK version with the regression (0.1.55+), the test fails + if and only if the proxy fails to strip the pattern. + """ + returncode, _stdout, stderr, captured = await _run_reproduction( + route_through_proxy=True + ) + _assert_no_forbidden_patterns(captured, returncode, stderr) + + def test_subprocess_module_available(): """Sentinel test: the subprocess module must be importable so the main reproduction test can spawn the CLI. Catches sandboxed CI From d702bcfae26e58fb6b087d6effebcc9c34c091b0 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 08:34:38 +0000 Subject: [PATCH 16/34] test(copilot/sdk-compat): skip bare-CLI reproduction when proxy enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `claude_agent_use_compat_proxy=True` the operator has explicitly opted into the workaround. The bare-CLI reproduction stops being a useful signal in that mode — what matters is the *upstream* (post- proxy) staying clean, which is covered by `test_cli_via_compat_proxy_emits_clean_requests_to_upstream`. Skip the bare test in that case so the dev-preview branch (0.1.58 + proxy on) goes fully green instead of having an intentional-but-loud failure on every CI run. When the proxy is disabled (the default on the standalone proxy and plumbing PRs), the bare test continues to run unchanged and serves as the regression detector for the bundled CLI version. --- .../copilot/sdk/cli_openrouter_compat_test.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index f66dad0800..8ae25b7131 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -484,7 +484,23 @@ async def test_cli_does_not_send_openrouter_incompatible_features(): this passes naturally. On a broken pin (0.1.55+, bundled CLI 2.1.91+) it fails — that failure IS the bisect signal we use to verify which SDK versions need the workaround. + + Skipped when ``claude_agent_use_compat_proxy=True`` because in that + configuration the operator has explicitly opted into the workaround + and the bare-CLI behaviour is moot — what matters is that the + *upstream* (post-proxy) sees clean requests, which is covered by + ``test_cli_via_compat_proxy_emits_clean_requests_to_upstream``. """ + from backend.copilot.config import ChatConfig + + if ChatConfig().claude_agent_use_compat_proxy: + pytest.skip( + "Compat proxy is enabled in the active config — the bare-CLI " + "reproduction is not a meaningful signal here. The proxy-routed " + "variant `test_cli_via_compat_proxy_emits_clean_requests_to_upstream` " + "is the regression guard for this configuration." + ) + returncode, _stdout, stderr, captured = await _run_reproduction( route_through_proxy=False ) From 349daf48f7f752fd2446bf56929f95c0a96efd8c Mon Sep 17 00:00:00 2001 From: majdyz Date: Sat, 11 Apr 2026 10:27:46 +0000 Subject: [PATCH 17/34] test(copilot/config): flip default-compat-proxy test for dev preview Dev-preview flips ``claude_agent_use_compat_proxy`` default to True so the bundled CLI in claude-agent-sdk 0.1.58 works out of the box. Update the no-env-var test accordingly so rebasing the upstream config test on this branch doesn't fail. --- autogpt_platform/backend/backend/copilot/config_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index ea6829227d..67b6e961cc 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -172,6 +172,6 @@ class TestClaudeAgentUseCompatProxyEnvFallback: self, monkeypatch: pytest.MonkeyPatch ) -> None: cfg = ChatConfig() - # Default is False on this branch; the dev-preview branch - # flips it to True but that's a separate PR. - assert cfg.claude_agent_use_compat_proxy is False + # Dev-preview branch defaults compat_proxy to True (the + # bundled CLI in claude-agent-sdk 0.1.58 needs the proxy). + assert cfg.claude_agent_use_compat_proxy is True From 85f64de4cf01a1fb9b55891f9167848efa124968 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 07:23:41 +0000 Subject: [PATCH 18/34] fix(copilot): address review feedback on compat proxy PR - Redact upstream URL from proxy error log to prevent leaking internal hostnames (openrouter_compat_proxy.py line 431) - Remove type: ignore suppressors from cli_openrouter_compat_test.py, using cast instead for the untyped SDK import - Fix validator precedence: replace field_validator with model_validator so explicit ChatConfig(claude_agent_use_compat_proxy=False) is not overridden by the unprefixed CLAUDE_AGENT_USE_COMPAT_PROXY env var - Add regression test for explicit-kwarg precedence --- .../backend/backend/copilot/config.py | 52 ++++++++++--------- .../backend/backend/copilot/config_test.py | 9 ++++ .../copilot/sdk/cli_openrouter_compat_test.py | 6 ++- .../copilot/sdk/openrouter_compat_proxy.py | 2 +- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 949f010701..7132c22dc4 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -3,7 +3,7 @@ import os from typing import Literal -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings from backend.util.clients import OPENROUTER_BASE_URL @@ -350,34 +350,36 @@ class ChatConfig(BaseSettings): v = os.getenv("CLAUDE_AGENT_CLI_PATH") return v - @field_validator("claude_agent_use_compat_proxy", mode="before") + @model_validator(mode="before") @classmethod - def get_claude_agent_use_compat_proxy(cls, v): - """Resolve the compat-proxy opt-in from environment. + def _inject_unprefixed_compat_proxy_env(cls, values): + """Inject the unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` env var + as a fallback for the ``claude_agent_use_compat_proxy`` field. - Accepts either ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` (the - Pydantic-prefixed form) or the unprefixed - ``CLAUDE_AGENT_USE_COMPAT_PROXY`` — same dual-name pattern as - ``claude_agent_cli_path`` above and ``api_key`` / ``base_url`` - further up. Returning the raw string lets Pydantic handle the - usual truthy/falsy coercion (``"1"``, ``"true"``, ``"yes"``, - ``"on"`` → True), so operators get the same behaviour they'd - get from the prefixed env var. + Unlike ``claude_agent_cli_path`` (which defaults to ``None`` and + can use a simple ``if not v`` guard), this field defaults to + ``True``, so a ``mode="before"`` field validator cannot + distinguish "caller passed ``False`` explicitly" from "Pydantic + resolved the default ``True``" — both arrive as the raw value. - Note: unlike the ``claude_agent_cli_path`` case, this field has - a non-``None`` default (``False``), so Pydantic passes the - default bool into the validator when no value is set — a - simple ``if v is None`` check wouldn't fire. We instead inspect - the raw process env directly: if the prefixed var is set we - let Pydantic's value stand; otherwise the unprefixed var wins. + Using a ``model_validator(mode="before")`` lets us inspect the + full input dict: if the key is absent AND the prefixed env var + ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` is not set, we inject the + unprefixed value so Pydantic can coerce it (``"1"``/``"true"`` + → ``True``). Explicit kwargs always take precedence because + they appear in *values* before this validator runs. """ - if os.getenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY") is not None: - # Prefixed var is set — trust Pydantic's parsed value. - return v - unprefixed = os.getenv("CLAUDE_AGENT_USE_COMPAT_PROXY") - if unprefixed is not None: - return unprefixed - return v + if not isinstance(values, dict): + return values + key = "claude_agent_use_compat_proxy" + if key not in values: + # No explicit kwarg and Pydantic hasn't injected the + # prefixed env var yet — check the unprefixed form. + if os.getenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY") is None: + unprefixed = os.getenv("CLAUDE_AGENT_USE_COMPAT_PROXY") + if unprefixed is not None: + values[key] = unprefixed + return values # Prompt paths for different contexts PROMPT_PATHS: dict[str, str] = { diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index 67b6e961cc..fb7ff85174 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -175,3 +175,12 @@ class TestClaudeAgentUseCompatProxyEnvFallback: # Dev-preview branch defaults compat_proxy to True (the # bundled CLI in claude-agent-sdk 0.1.58 needs the proxy). assert cfg.claude_agent_use_compat_proxy is True + + def test_explicit_kwarg_not_overridden_by_unprefixed_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: explicit ChatConfig(claude_agent_use_compat_proxy=False) + must not be overridden by the unprefixed env var.""" + monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "true") + cfg = ChatConfig(claude_agent_use_compat_proxy=False) + assert cfg.claude_agent_use_compat_proxy is False diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 8ae25b7131..ef0e122c32 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -287,11 +287,13 @@ def _resolve_cli_path() -> Path | None: return candidate if candidate.is_file() else None try: - from claude_agent_sdk._internal.transport.subprocess_cli import ( # type: ignore[import-untyped] + from typing import cast + + from claude_agent_sdk._internal.transport.subprocess_cli import ( SubprocessCLITransport, ) - bundled = SubprocessCLITransport._find_bundled_cli(None) # type: ignore[arg-type] + bundled = cast(str, SubprocessCLITransport._find_bundled_cli(None)) return Path(bundled) if bundled else None except Exception as e: # pragma: no cover - import-time guard logger.warning("Could not locate bundled Claude CLI: %s", e) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index dc1b9adbd8..dd9cd72a86 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -428,7 +428,7 @@ class OpenRouterCompatProxy: # internal hostnames, ports, or stack frames (CodeQL # `py/stack-trace-exposure`). logger.warning( - "OpenRouter compat proxy upstream error: %s (url=%s)", e, upstream_url + "OpenRouter compat proxy upstream error: %s", type(e).__name__ ) return web.Response(status=502, text="upstream error") From cc3bac13c5f17ea425eeea0745079cf0cac90b44 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 07:31:45 +0000 Subject: [PATCH 19/34] fix(copilot): address second CodeRabbit review cycle - Fix docstring: default is True, not False (config_test.py) - Redact exception message from stream-error log for consistency with upstream-error log (openrouter_compat_proxy.py) --- autogpt_platform/backend/backend/copilot/config_test.py | 2 +- .../backend/backend/copilot/sdk/openrouter_compat_proxy.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index fb7ff85174..60400ac41a 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -131,7 +131,7 @@ class TestClaudeAgentUseCompatProxyEnvFallback: prefixed ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` env var and the unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` form. Regression guard for the bool-default pitfall: the field has a non-None - default (``False``), so Pydantic passes the default into the + default (``True``), so Pydantic passes the default into the validator when no value is provided and a naive ``if v is None`` check would never fire. """ diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index dd9cd72a86..5001c575a5 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -463,7 +463,9 @@ class OpenRouterCompatProxy: raise except aiohttp.ClientError as e: stream_error = e - logger.warning("OpenRouter compat proxy stream interrupted: %s", e) + logger.warning( + "OpenRouter compat proxy stream interrupted: %s", type(e).__name__ + ) finally: if not cancelled: upstream_response.release() From 05477f2daa2db9aac8fe6001a7c71ce78d77a3cf Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 07:43:12 +0000 Subject: [PATCH 20/34] fix(copilot): address third CodeRabbit review cycle on proxy - Preserve multi-valued response headers (e.g. Set-Cookie) by using clean_response_headers -> CIMultiDict instead of dict(headers) - Use sock_connect + sock_read timeouts instead of total so long-lived SSE streaming responses aren't killed after 600s - Log the configured bind_host instead of hardcoded 127.0.0.1 --- .../copilot/sdk/openrouter_compat_proxy.py | 82 +++++++++++++++---- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index 5001c575a5..d940a83f73 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -213,12 +213,23 @@ def clean_request_body_bytes(body_bytes: bytes) -> bytes: return json.dumps(payload, separators=(",", ":")).encode("utf-8") +def _parse_connection_tokens(headers: dict[str, str]) -> set[str]: + """Extract hop-by-hop header names from the ``Connection`` field.""" + connection_header = next( + (value for name, value in headers.items() if name.lower() == "connection"), + "", + ) + return { + token.strip().lower() for token in connection_header.split(",") if token.strip() + } + + def clean_request_headers(headers: dict[str, str]) -> dict[str, str]: """Drop hop-by-hop headers and rewrite ``anthropic-beta`` to remove forbidden tokens. Returns a fresh dict the caller can pass through to the upstream client without further mutation. - Per RFC 7230 §6.1, intermediaries must drop the static hop-by-hop + Per RFC 7230 section 6.1, intermediaries must drop the static hop-by-hop set above **and** every header name listed in the incoming ``Connection`` field value (case-insensitive). The latter is how extension hop-by-hop headers are signalled per-connection. @@ -226,17 +237,7 @@ def clean_request_headers(headers: dict[str, str]) -> dict[str, str]: Callers should pass an already-materialised ``dict`` (e.g. ``dict(request.headers)``) so this function stays simple. """ - # Parse ``Connection: a, b, c`` into a lowercase token set so we - # can drop any header the sender explicitly marked as hop-by-hop - # on this connection. This is separate from the static set - # above — extension headers can be anything. - connection_header = next( - (value for name, value in headers.items() if name.lower() == "connection"), - "", - ) - connection_tokens: set[str] = { - token.strip().lower() for token in connection_header.split(",") if token.strip() - } + connection_tokens = _parse_connection_tokens(headers) cleaned: dict[str, str] = {} for name, value in headers.items(): @@ -253,6 +254,40 @@ def clean_request_headers(headers: dict[str, str]) -> dict[str, str]: return cleaned +def clean_response_headers( + headers: "Any", +) -> list[tuple[str, str]]: + """Like :func:`clean_request_headers` but preserves multi-valued + headers (e.g. ``Set-Cookie``). Accepts any mapping-like object + whose ``.items()`` yields ``(name, value)`` pairs — including + aiohttp's ``CIMultiDictProxy`` which can have duplicate keys. + + Returns a list of ``(name, value)`` tuples suitable for passing + to ``web.StreamResponse(headers=...)`` via ``CIMultiDict``. + """ + connection_tokens: set[str] = set() + for name, value in headers.items(): + if name.lower() == "connection": + connection_tokens = { + t.strip().lower() for t in value.split(",") if t.strip() + } + break + + cleaned: list[tuple[str, str]] = [] + for name, value in headers.items(): + lower_name = name.lower() + if lower_name in _HOP_BY_HOP_HEADERS or lower_name in connection_tokens: + continue + if lower_name == "anthropic-beta": + stripped = strip_forbidden_anthropic_beta_header(value) + if stripped is None: + continue + cleaned.append((name, stripped)) + continue + cleaned.append((name, value)) + return cleaned + + # --------------------------------------------------------------------------- # The proxy server # --------------------------------------------------------------------------- @@ -312,8 +347,16 @@ class OpenRouterCompatProxy: """ if self._runner is not None: return # already started + # Use sock_connect + sock_read instead of total so long-lived + # SSE / streaming responses aren't killed after request_timeout. + # total=None means no cumulative limit; sock_read is the per-chunk + # idle timeout (time between data arriving on the socket). client = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=self._request_timeout) + timeout=aiohttp.ClientTimeout( + total=None, + sock_connect=self._request_timeout, + sock_read=self._request_timeout, + ) ) app = web.Application() # Catch every method + path so we can also forward GETs @@ -358,7 +401,11 @@ class OpenRouterCompatProxy: # endpoint is anyway discoverable from the config the operator # already has access to. The detailed upstream is exposed via # the ``target_base_url`` property for callers that need it. - logger.info("OpenRouter compat proxy listening on 127.0.0.1:%d", self._port) + logger.info( + "OpenRouter compat proxy listening on %s:%d", + self._bind_host, + self._port, + ) async def stop(self) -> None: """Stop accepting connections and release the port.""" @@ -433,10 +480,13 @@ class OpenRouterCompatProxy: return web.Response(status=502, text="upstream error") # Stream the response back unchanged (apart from hop-by-hop - # header filtering). + # header filtering). Use clean_response_headers to preserve + # multi-valued headers like Set-Cookie that dict() would drop. + from multidict import CIMultiDict + downstream = web.StreamResponse( status=upstream_response.status, - headers=clean_request_headers(dict(upstream_response.headers)), + headers=CIMultiDict(clean_response_headers(upstream_response.headers)), ) await downstream.prepare(request) # Track whether the stream terminated cleanly. A mid-stream From 7f782d46760cc323649bdc3c347c2b939a81be51 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 09:33:11 +0000 Subject: [PATCH 21/34] ci(backend): add test to validate CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS env var Adds a new test that spawns the CLI with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (without the compat proxy) and checks whether the context-management-2025-06-27 beta header is stripped. If this test passes in CI, the proxy can be removed entirely in favour of the simpler env var approach. --- .../copilot/sdk/cli_openrouter_compat_test.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index ef0e122c32..d0f7e8dafa 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -304,6 +304,7 @@ async def _run_cli_against_fake_server( cli_path: Path, fake_server_port: int, timeout_seconds: float, + extra_env: dict[str, str] | None = None, ) -> tuple[int, str, str]: """Spawn the CLI pointed at the fake Anthropic server and feed it a single ``user`` message via stream-json on stdin. @@ -323,6 +324,7 @@ async def _run_cli_against_fake_server( # mid-test (telemetry, plugin marketplace fetch). "DISABLE_TELEMETRY": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + **(extra_env or {}), } # The CLI accepts stream-json input on stdin in `query` mode. A @@ -389,7 +391,9 @@ async def _run_cli_against_fake_server( async def _run_reproduction( - *, route_through_proxy: bool + *, + route_through_proxy: bool, + extra_env: dict[str, str] | None = None, ) -> tuple[int, str, str, list[_CapturedRequest]]: """Spawn the CLI against a fake Anthropic API and return what the *upstream* (post-proxy if any) saw. @@ -430,6 +434,7 @@ async def _run_reproduction( cli_path=cli_path, fake_server_port=target_port, timeout_seconds=30.0, + extra_env=extra_env, ) finally: if proxy is not None: @@ -532,6 +537,25 @@ async def test_cli_via_compat_proxy_emits_clean_requests_to_upstream(): _assert_no_forbidden_patterns(captured, returncode, stderr) +@pytest.mark.asyncio +async def test_disable_experimental_betas_env_var_strips_headers(): + """Validate whether ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` is + sufficient to strip the ``context-management-2025-06-27`` beta header + when ``ANTHROPIC_BASE_URL`` points to a non-Anthropic endpoint + (simulating OpenRouter). + + If this test passes, the compat proxy is unnecessary and can be + removed — the env var alone is enough. If it fails, the CLI's + provider-detection logic does not honour the env var for custom + base URLs and the proxy remains required. + """ + returncode, _stdout, stderr, captured = await _run_reproduction( + route_through_proxy=False, + extra_env={"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"}, + ) + _assert_no_forbidden_patterns(captured, returncode, stderr) + + def test_subprocess_module_available(): """Sentinel test: the subprocess module must be importable so the main reproduction test can spawn the CLI. Catches sandboxed CI From e92ecbbb7c80540322b5896087ddfaca9ae41e8a Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 10:08:29 +0000 Subject: [PATCH 22/34] fix(backend): address review comments on SDK upgrade PR - Make strip_forbidden_betas_from_body non-mutating (returns shallow copy instead of modifying caller's dict in-place) - Add os.access(X_OK) validation for claude_agent_cli_path to reject non-executable paths at config load time - Replace hardcoded /v1 path dedup with generic urlparse-based logic that handles any API version prefix in the target URL --- .../backend/backend/copilot/config.py | 5 +++ .../copilot/sdk/openrouter_compat_proxy.py | 35 +++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 7132c22dc4..0206d7930b 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -348,6 +348,11 @@ class ChatConfig(BaseSettings): v = os.getenv("CHAT_CLAUDE_AGENT_CLI_PATH") if not v: v = os.getenv("CLAUDE_AGENT_CLI_PATH") + if v and not os.access(v, os.X_OK): + raise ValueError( + f"claude_agent_cli_path '{v}' is not an executable file. " + "Check the path and file permissions." + ) return v @model_validator(mode="before") diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py index d940a83f73..103046942e 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py @@ -162,6 +162,9 @@ def strip_forbidden_betas_from_body(payload: Any) -> Any: """Remove forbidden tokens from the ``betas`` array of an Anthropic Messages API request body, if present. + Returns a shallow copy with the ``betas`` key cleaned — the input + dict is never mutated. + The Messages API accepts a top-level ``betas: list[str]`` parameter used to opt into beta features. We drop tokens in :data:`_FORBIDDEN_BETA_TOKENS` so OpenRouter's check passes. @@ -169,15 +172,13 @@ def strip_forbidden_betas_from_body(payload: Any) -> Any: if not isinstance(payload, dict): return payload betas = payload.get("betas") - if isinstance(betas, list): - cleaned_betas = [b for b in betas if b not in _FORBIDDEN_BETA_TOKENS] - if cleaned_betas: - payload["betas"] = cleaned_betas - else: - # Drop the empty array entirely so OpenRouter doesn't even - # see an empty `betas` field. - payload.pop("betas", None) - return payload + if not isinstance(betas, list): + return payload + cleaned_betas = [b for b in betas if b not in _FORBIDDEN_BETA_TOKENS] + result = {k: v for k, v in payload.items() if k != "betas"} + if cleaned_betas: + result["betas"] = cleaned_betas + return result def strip_forbidden_anthropic_beta_header(value: str | None) -> str | None: @@ -444,12 +445,18 @@ class OpenRouterCompatProxy: # ``/api/v1/v1/messages``. Strip a leading ``/v1`` from the # incoming path if the target already ends with ``/v1`` (or # similar API-version segment). + # Deduplicate API version prefix: if the target URL already + # contains a versioned path segment (e.g. ``/api/v1``) and the + # incoming request path starts with the same segment, strip it + # to avoid ``/api/v1/v1/messages``. + from urllib.parse import urlparse + target_base = self._target_base_url - target_lower = target_base.lower() - for prefix in ("/v1",): - if target_lower.endswith(prefix) and upstream_path.startswith(prefix + "/"): - upstream_path = upstream_path[len(prefix) :] - break + target_path = urlparse(target_base).path.rstrip("/") + if target_path and upstream_path.startswith(target_path + "/"): + upstream_path = upstream_path[len(target_path) :] + elif target_path and upstream_path == target_path: + upstream_path = "/" upstream_url = f"{target_base}{upstream_path}" body_bytes = await request.read() From 8e9bb083b2dc7eda1ef396ee2d6944f6f46645e8 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 10:53:07 +0000 Subject: [PATCH 23/34] refactor(backend): replace compat proxy with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS env var --- .../backend/backend/copilot/config.py | 55 +- .../backend/backend/copilot/config_test.py | 62 -- .../copilot/sdk/cli_openrouter_compat_test.py | 99 +-- .../copilot/sdk/openrouter_compat_proxy.py | 559 -------------- .../sdk/openrouter_compat_proxy_test.py | 695 ------------------ .../backend/copilot/sdk/sdk_compat_test.py | 101 +-- .../backend/backend/copilot/sdk/service.py | 118 +-- autogpt_platform/backend/pyproject.toml | 2 +- 8 files changed, 46 insertions(+), 1645 deletions(-) delete mode 100644 autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py delete mode 100644 autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 0206d7930b..a26b105347 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -3,7 +3,7 @@ import os from typing import Literal -from pydantic import Field, field_validator, model_validator +from pydantic import Field, field_validator from pydantic_settings import BaseSettings from backend.util.clients import OPENROUTER_BASE_URL @@ -186,28 +186,6 @@ class ChatConfig(BaseSettings): "or the unprefixed `CLAUDE_AGENT_CLI_PATH` environment variable " "(same pattern as `api_key` / `base_url`).", ) - claude_agent_use_compat_proxy: bool = Field( - default=True, - description="Run the in-process OpenRouter compatibility proxy " - "(`backend.copilot.sdk.openrouter_compat_proxy`) in front of the " - "Claude Code CLI. The proxy strips `tool_reference` content " - "blocks and the `context-management-2025-06-27` beta header / " - "field from outgoing requests so newer SDK / CLI versions stop " - "tripping OpenRouter's stricter validation. Defaults to True " - "because the bundled CLI in `claude-agent-sdk >= 0.1.55` requires " - "the proxy. Orthogonal to `claude_agent_cli_path` — the override " - "picks the binary, the proxy rewrites whatever the binary sends. " - "Disable explicitly only if you've pinned `claude-agent-sdk` to " - "a version whose bundled CLI is in " - "`_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT` (2.1.63 or 2.1.70). " - "Reads from `CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY` or the " - "unprefixed `CLAUDE_AGENT_USE_COMPAT_PROXY` environment " - "variable (same pattern as `claude_agent_cli_path`). Only " - "takes effect when the session has an Anthropic-compatible " - "upstream to forward to — direct-Anthropic sessions skip the " - "proxy entirely to avoid silently re-routing through " - "OpenRouter.", - ) use_openrouter: bool = Field( default=True, description="Enable routing API calls through the OpenRouter proxy. " @@ -355,37 +333,6 @@ class ChatConfig(BaseSettings): ) return v - @model_validator(mode="before") - @classmethod - def _inject_unprefixed_compat_proxy_env(cls, values): - """Inject the unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` env var - as a fallback for the ``claude_agent_use_compat_proxy`` field. - - Unlike ``claude_agent_cli_path`` (which defaults to ``None`` and - can use a simple ``if not v`` guard), this field defaults to - ``True``, so a ``mode="before"`` field validator cannot - distinguish "caller passed ``False`` explicitly" from "Pydantic - resolved the default ``True``" — both arrive as the raw value. - - Using a ``model_validator(mode="before")`` lets us inspect the - full input dict: if the key is absent AND the prefixed env var - ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` is not set, we inject the - unprefixed value so Pydantic can coerce it (``"1"``/``"true"`` - → ``True``). Explicit kwargs always take precedence because - they appear in *values* before this validator runs. - """ - if not isinstance(values, dict): - return values - key = "claude_agent_use_compat_proxy" - if key not in values: - # No explicit kwarg and Pydantic hasn't injected the - # prefixed env var yet — check the unprefixed form. - if os.getenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY") is None: - unprefixed = os.getenv("CLAUDE_AGENT_USE_COMPAT_PROXY") - if unprefixed is not None: - values[key] = unprefixed - return values - # Prompt paths for different contexts PROMPT_PATHS: dict[str, str] = { "default": "prompts/chat_system.md", diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index 60400ac41a..413a89277a 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -19,8 +19,6 @@ _ENV_VARS_TO_CLEAR = ( "OPENAI_BASE_URL", "CHAT_CLAUDE_AGENT_CLI_PATH", "CLAUDE_AGENT_CLI_PATH", - "CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY", - "CLAUDE_AGENT_USE_COMPAT_PROXY", ) @@ -124,63 +122,3 @@ class TestClaudeAgentCliPathEnvFallback: def test_no_env_var_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch) -> None: cfg = ChatConfig() assert cfg.claude_agent_cli_path is None - - -class TestClaudeAgentUseCompatProxyEnvFallback: - """``claude_agent_use_compat_proxy`` accepts both the Pydantic- - prefixed ``CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY`` env var and the - unprefixed ``CLAUDE_AGENT_USE_COMPAT_PROXY`` form. Regression - guard for the bool-default pitfall: the field has a non-None - default (``True``), so Pydantic passes the default into the - validator when no value is provided and a naive ``if v is None`` - check would never fire. - """ - - def test_prefixed_env_var_enables_proxy( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY", "true") - cfg = ChatConfig() - assert cfg.claude_agent_use_compat_proxy is True - - def test_unprefixed_env_var_enables_proxy( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "true") - cfg = ChatConfig() - assert cfg.claude_agent_use_compat_proxy is True - - def test_unprefixed_env_var_respects_falsy_value( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "false") - cfg = ChatConfig() - assert cfg.claude_agent_use_compat_proxy is False - - def test_prefixed_wins_over_unprefixed( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """When both are set, the Pydantic-prefixed var is authoritative - so the validator doesn't silently clobber an explicit - ``CHAT_...=false`` with an unprefixed ``=true``.""" - monkeypatch.setenv("CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY", "false") - monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "true") - cfg = ChatConfig() - assert cfg.claude_agent_use_compat_proxy is False - - def test_no_env_var_uses_field_default( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - cfg = ChatConfig() - # Dev-preview branch defaults compat_proxy to True (the - # bundled CLI in claude-agent-sdk 0.1.58 needs the proxy). - assert cfg.claude_agent_use_compat_proxy is True - - def test_explicit_kwarg_not_overridden_by_unprefixed_env( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Regression: explicit ChatConfig(claude_agent_use_compat_proxy=False) - must not be overridden by the unprefixed env var.""" - monkeypatch.setenv("CLAUDE_AGENT_USE_COMPAT_PROXY", "true") - cfg = ChatConfig(claude_agent_use_compat_proxy=False) - assert cfg.claude_agent_use_compat_proxy is False diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index d0f7e8dafa..3b20cd2b68 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -392,17 +392,10 @@ async def _run_cli_against_fake_server( async def _run_reproduction( *, - route_through_proxy: bool, extra_env: dict[str, str] | None = None, ) -> tuple[int, str, str, list[_CapturedRequest]]: """Spawn the CLI against a fake Anthropic API and return what the - *upstream* (post-proxy if any) saw. - - When ``route_through_proxy`` is True, the CLI talks to the - ``OpenRouterCompatProxy`` and the proxy forwards to the fake - upstream. The fake upstream is what records the requests, so the - captured bodies are what OpenRouter would actually have received — - *after* the proxy's stripping pass. + server saw. """ cli_path = _resolve_cli_path() if cli_path is None or not cli_path.is_file(): @@ -415,30 +408,14 @@ async def _run_reproduction( captured: list[_CapturedRequest] = [] upstream_runner, upstream_port = await _start_fake_anthropic_server(captured) - proxy = None - target_port = upstream_port try: - if route_through_proxy: - from backend.copilot.sdk.openrouter_compat_proxy import ( - OpenRouterCompatProxy, - ) - - proxy = OpenRouterCompatProxy( - target_base_url=f"http://127.0.0.1:{upstream_port}" - ) - await proxy.start() - # Pull the bound port out of the proxy URL. - target_port = int(proxy.local_url.rsplit(":", 1)[1]) - returncode, stdout, stderr = await _run_cli_against_fake_server( cli_path=cli_path, - fake_server_port=target_port, + fake_server_port=upstream_port, timeout_seconds=30.0, extra_env=extra_env, ) finally: - if proxy is not None: - await proxy.stop() await upstream_runner.cleanup() return returncode, stdout, stderr, captured @@ -470,10 +447,9 @@ def _assert_no_forbidden_patterns( "`claude-agent-sdk` above 0.1.45. See " "https://github.com/Significant-Gravitas/AutoGPT/pull/12294 and " "https://github.com/anthropics/claude-agent-sdk-python/issues/789. " - "If you intended to upgrade, you must enable the in-process compat " - "proxy (`CLAUDE_AGENT_USE_COMPAT_PROXY=true` or the prefixed " - "`CHAT_CLAUDE_AGENT_USE_COMPAT_PROXY=true`) or use a known-good " - "CLI binary via `claude_agent_cli_path` (env: " + "If you intended to upgrade, ensure " + "`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` is set in the SDK env " + "or use a known-good CLI binary via `claude_agent_cli_path` (env: " "`CLAUDE_AGENT_CLI_PATH` or `CHAT_CLAUDE_AGENT_CLI_PATH`)." ) @@ -483,74 +459,31 @@ async def test_cli_does_not_send_openrouter_incompatible_features(): """End-to-end OpenRouter compatibility reproduction (bare CLI path). Spawns the bundled (or overridden) Claude Code CLI against a fake - Anthropic API server WITHOUT the compat proxy in the loop, captures - every request body it sends, and asserts that none of them contain - the two known OpenRouter-breaking features. + Anthropic API server, captures every request body it sends, and + asserts that none of them contain the two known OpenRouter-breaking + features. On a clean SDK pin (0.1.45 or 0.1.47, bundled CLI 2.1.63 or 2.1.70) this passes naturally. On a broken pin (0.1.55+, bundled CLI 2.1.91+) it fails — that failure IS the bisect signal we use to verify which SDK versions need the workaround. - - Skipped when ``claude_agent_use_compat_proxy=True`` because in that - configuration the operator has explicitly opted into the workaround - and the bare-CLI behaviour is moot — what matters is that the - *upstream* (post-proxy) sees clean requests, which is covered by - ``test_cli_via_compat_proxy_emits_clean_requests_to_upstream``. """ - from backend.copilot.config import ChatConfig - - if ChatConfig().claude_agent_use_compat_proxy: - pytest.skip( - "Compat proxy is enabled in the active config — the bare-CLI " - "reproduction is not a meaningful signal here. The proxy-routed " - "variant `test_cli_via_compat_proxy_emits_clean_requests_to_upstream` " - "is the regression guard for this configuration." - ) - - returncode, _stdout, stderr, captured = await _run_reproduction( - route_through_proxy=False - ) - _assert_no_forbidden_patterns(captured, returncode, stderr) - - -@pytest.mark.asyncio -async def test_cli_via_compat_proxy_emits_clean_requests_to_upstream(): - """End-to-end test for the compat proxy workaround. - - Spawns the bundled CLI against an in-process fake Anthropic API - server WITH the ``OpenRouterCompatProxy`` in front, then asserts - that the *upstream* sees clean requests — no `tool_reference` - blocks, no `context-management-2025-06-27` beta header — even - when the bundled CLI itself would have sent them. - - This is the regression guard for the proxy: if the proxy ever - stops stripping a known forbidden pattern, this test catches it. - On a SDK version where the bare CLI is already clean (0.1.45 / - 0.1.47), the proxy is a no-op and the test passes trivially. - On a SDK version with the regression (0.1.55+), the test fails - if and only if the proxy fails to strip the pattern. - """ - returncode, _stdout, stderr, captured = await _run_reproduction( - route_through_proxy=True - ) + returncode, _stdout, stderr, captured = await _run_reproduction() _assert_no_forbidden_patterns(captured, returncode, stderr) @pytest.mark.asyncio async def test_disable_experimental_betas_env_var_strips_headers(): - """Validate whether ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` is - sufficient to strip the ``context-management-2025-06-27`` beta header - when ``ANTHROPIC_BASE_URL`` points to a non-Anthropic endpoint - (simulating OpenRouter). + """Validate that ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` strips + the ``context-management-2025-06-27`` beta header when + ``ANTHROPIC_BASE_URL`` points to a non-Anthropic endpoint (simulating + OpenRouter). - If this test passes, the compat proxy is unnecessary and can be - removed — the env var alone is enough. If it fails, the CLI's - provider-detection logic does not honour the env var for custom - base URLs and the proxy remains required. + This is the main regression guard: the env var is injected by + ``service.py`` into every CLI subprocess so newer SDK / CLI versions + work with OpenRouter without any proxy. """ returncode, _stdout, stderr, captured = await _run_reproduction( - route_through_proxy=False, extra_env={"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"}, ) _assert_no_forbidden_patterns(captured, returncode, stderr) diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py deleted file mode 100644 index 103046942e..0000000000 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy.py +++ /dev/null @@ -1,559 +0,0 @@ -"""Tiny in-process HTTP middleware that makes the Claude Code CLI work -against OpenRouter on **any** ``claude-agent-sdk`` version. - -Background ----------- -We've been pinned at ``claude-agent-sdk==0.1.45`` (bundled CLI 2.1.63) -since `PR #12294`_ because every newer CLI version sends one of two -features that OpenRouter rejects: - -1. **`tool_reference` content blocks** in ``tool_result.content`` — - introduced in CLI 2.1.69. OpenRouter's stricter Zod validation - refuses requests containing them with:: - - messages[N].content[0].content: Invalid input: expected string, received array - -2. **`context-management-2025-06-27` beta header** — sent in either the - request body's ``betas`` array or the ``anthropic-beta`` HTTP header. - OpenRouter responds:: - - 400 No endpoints available that support Anthropic's context - management features (context-management-2025-06-27). - - Tracked upstream at `claude-agent-sdk-python#789`_. - -This module starts a tiny aiohttp server that: - -* listens on ``127.0.0.1:RANDOM_PORT``, -* receives every CLI request that would normally go to - ``ANTHROPIC_BASE_URL``, -* strips the two forbidden patterns from the body and headers, -* forwards the cleaned request to the real upstream - (``proxy_target_base_url``, e.g. ``https://openrouter.ai/api/v1``), -* streams the response back to the CLI unchanged. - -The proxy is wired via :class:`backend.copilot.config.ChatConfig.claude_agent_use_compat_proxy`. -When the flag is on, :mod:`backend.copilot.sdk.service` starts a proxy -per session, sets ``ANTHROPIC_BASE_URL`` in the SDK's ``env`` to point -at the proxy, then tears it down after the session ends. - -Why a separate proxy instead of a custom HTTP transport in the SDK? -------------------------------------------------------------------- -The Python SDK delegates **all** HTTP traffic to the bundled Claude -Code CLI subprocess. Once the CLI is spawned, the only seam left is -the network — there is no in-process hook for "modify outgoing -request before it leaves the CLI". The proxy lives at that seam. - -This module is intentionally orthogonal to the -:attr:`ChatConfig.claude_agent_cli_path` override: - -* ``cli_path`` lets us swap **which CLI binary** we run. -* this proxy lets us **rewrite what any CLI binary sends**. - -The two can be combined or used independently. - -.. _PR #12294: https://github.com/Significant-Gravitas/AutoGPT/pull/12294 -.. _claude-agent-sdk-python#789: https://github.com/anthropics/claude-agent-sdk-python/issues/789 -""" - -from __future__ import annotations - -import asyncio -import json -import logging -from typing import Any - -import aiohttp -from aiohttp import web - -logger = logging.getLogger(__name__) - -# Header values OpenRouter rejects. We strip exactly these tokens from -# the comma-separated ``anthropic-beta`` header value (preserving any -# other betas the CLI requests). -_FORBIDDEN_BETA_TOKENS: frozenset[str] = frozenset( - { - "context-management-2025-06-27", - } -) - -# Hop-by-hop headers we must NOT forward through the proxy. Per -# RFC 7230 §6.1, these are connection-specific and must be regenerated -# by each intermediary. ``host`` is also stripped because aiohttp -# generates the correct ``Host`` header for the upstream URL itself. -# -# The canonical header name defined in RFC 7230 §4.4 is ``Trailer`` -# (singular); some SDKs / legacy proxies also emit the plural -# ``Trailers`` so we accept both forms just in case. Intermediaries -# must additionally drop every header name listed in the incoming -# ``Connection`` field value (§6.1 "extension hop-by-hop headers") — -# that's handled dynamically by :func:`clean_request_headers`. -_HOP_BY_HOP_HEADERS: frozenset[str] = frozenset( - { - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "trailers", - "transfer-encoding", - "upgrade", - "host", - # ``content-length`` is stripped because we may rewrite the - # body — aiohttp will recompute it on the upstream request. - "content-length", - } -) - - -# --------------------------------------------------------------------------- -# Pure helpers — exported so the unit tests can drive them directly without -# spinning up a server. -# --------------------------------------------------------------------------- - - -def strip_tool_reference_blocks(payload: Any) -> Any: - """Recursively remove ``tool_reference`` content blocks from - *payload*, returning the cleaned structure. - - The CLI's built-in ``ToolSearch`` tool emits these as part of - ``tool_result.content``:: - - {"type": "tool_reference", "tool_name": "mcp__copilot__find_block"} - - OpenRouter's stricter Zod validation rejects them. Removing them - is safe — they are metadata about which tools were searched, not - real model-visible content. The CLI's *internal* state still - contains them; only the wire format is rewritten. - """ - if isinstance(payload, dict): - # Drop the dict entirely if it IS a tool_reference block. The - # caller (a list comprehension below) discards None entries so - # we can return None to signal "remove me". - if payload.get("type") == "tool_reference": - return None - cleaned_dict: dict[str, Any] = {} - for key, value in payload.items(): - cleaned_value = strip_tool_reference_blocks(value) - # If a dict-valued child WAS a tool_reference block, - # drop the key entirely rather than writing `null` — - # otherwise schema-strict upstreams still reject the - # payload. Only applies when the original value was a - # dict; genuine None values in the input are preserved. - if cleaned_value is None and isinstance(value, dict): - continue - cleaned_dict[key] = cleaned_value - return cleaned_dict - if isinstance(payload, list): - cleaned_list: list[Any] = [] - for item in payload: - cleaned_item = strip_tool_reference_blocks(item) - if cleaned_item is None and isinstance(item, dict): - # Item was a tool_reference block — drop it from the - # list rather than leaving a None hole. - continue - cleaned_list.append(cleaned_item) - return cleaned_list - return payload - - -def strip_forbidden_betas_from_body(payload: Any) -> Any: - """Remove forbidden tokens from the ``betas`` array of an - Anthropic Messages API request body, if present. - - Returns a shallow copy with the ``betas`` key cleaned — the input - dict is never mutated. - - The Messages API accepts a top-level ``betas: list[str]`` parameter - used to opt into beta features. We drop tokens in - :data:`_FORBIDDEN_BETA_TOKENS` so OpenRouter's check passes. - """ - if not isinstance(payload, dict): - return payload - betas = payload.get("betas") - if not isinstance(betas, list): - return payload - cleaned_betas = [b for b in betas if b not in _FORBIDDEN_BETA_TOKENS] - result = {k: v for k, v in payload.items() if k != "betas"} - if cleaned_betas: - result["betas"] = cleaned_betas - return result - - -def strip_forbidden_anthropic_beta_header(value: str | None) -> str | None: - """Return *value* with forbidden tokens removed. - - The ``anthropic-beta`` HTTP header is a comma-separated list of - feature flags. We strip exactly the forbidden tokens, preserving - any others. Returns ``None`` if nothing remains (so the caller - can drop the header entirely). - """ - if not value: - return value - tokens = [token.strip() for token in value.split(",")] - kept = [token for token in tokens if token and token not in _FORBIDDEN_BETA_TOKENS] - if not kept: - return None - return ", ".join(kept) - - -def clean_request_body_bytes(body_bytes: bytes) -> bytes: - """Apply both body-level strippers to *body_bytes*, returning the - cleaned JSON. Falls back to the original bytes when the body - isn't valid JSON (the CLI shouldn't be sending non-JSON to the - Messages API, but be defensive).""" - if not body_bytes: - return body_bytes - try: - payload = json.loads(body_bytes.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - return body_bytes - payload = strip_tool_reference_blocks(payload) - payload = strip_forbidden_betas_from_body(payload) - return json.dumps(payload, separators=(",", ":")).encode("utf-8") - - -def _parse_connection_tokens(headers: dict[str, str]) -> set[str]: - """Extract hop-by-hop header names from the ``Connection`` field.""" - connection_header = next( - (value for name, value in headers.items() if name.lower() == "connection"), - "", - ) - return { - token.strip().lower() for token in connection_header.split(",") if token.strip() - } - - -def clean_request_headers(headers: dict[str, str]) -> dict[str, str]: - """Drop hop-by-hop headers and rewrite ``anthropic-beta`` to remove - forbidden tokens. Returns a fresh dict the caller can pass through - to the upstream client without further mutation. - - Per RFC 7230 section 6.1, intermediaries must drop the static hop-by-hop - set above **and** every header name listed in the incoming - ``Connection`` field value (case-insensitive). The latter is how - extension hop-by-hop headers are signalled per-connection. - - Callers should pass an already-materialised ``dict`` (e.g. - ``dict(request.headers)``) so this function stays simple. - """ - connection_tokens = _parse_connection_tokens(headers) - - cleaned: dict[str, str] = {} - for name, value in headers.items(): - lower_name = name.lower() - if lower_name in _HOP_BY_HOP_HEADERS or lower_name in connection_tokens: - continue - if lower_name == "anthropic-beta": - stripped = strip_forbidden_anthropic_beta_header(value) - if stripped is None: - continue - cleaned[name] = stripped - continue - cleaned[name] = value - return cleaned - - -def clean_response_headers( - headers: "Any", -) -> list[tuple[str, str]]: - """Like :func:`clean_request_headers` but preserves multi-valued - headers (e.g. ``Set-Cookie``). Accepts any mapping-like object - whose ``.items()`` yields ``(name, value)`` pairs — including - aiohttp's ``CIMultiDictProxy`` which can have duplicate keys. - - Returns a list of ``(name, value)`` tuples suitable for passing - to ``web.StreamResponse(headers=...)`` via ``CIMultiDict``. - """ - connection_tokens: set[str] = set() - for name, value in headers.items(): - if name.lower() == "connection": - connection_tokens = { - t.strip().lower() for t in value.split(",") if t.strip() - } - break - - cleaned: list[tuple[str, str]] = [] - for name, value in headers.items(): - lower_name = name.lower() - if lower_name in _HOP_BY_HOP_HEADERS or lower_name in connection_tokens: - continue - if lower_name == "anthropic-beta": - stripped = strip_forbidden_anthropic_beta_header(value) - if stripped is None: - continue - cleaned.append((name, stripped)) - continue - cleaned.append((name, value)) - return cleaned - - -# --------------------------------------------------------------------------- -# The proxy server -# --------------------------------------------------------------------------- - - -class OpenRouterCompatProxy: - """In-process HTTP proxy that rewrites Claude Code CLI requests on - the way to OpenRouter (or any other Anthropic-compatible gateway). - - Usage:: - - proxy = OpenRouterCompatProxy(target_base_url="https://openrouter.ai/api/v1") - await proxy.start() - try: - # Spawn the CLI with ANTHROPIC_BASE_URL=proxy.local_url - ... - finally: - await proxy.stop() - """ - - def __init__( - self, - target_base_url: str, - *, - bind_host: str = "127.0.0.1", - request_timeout: float = 600.0, - ) -> None: - self._target_base_url = target_base_url.rstrip("/") - self._bind_host = bind_host - self._request_timeout = request_timeout - self._runner: web.AppRunner | None = None - self._client: aiohttp.ClientSession | None = None - self._port: int | None = None - - @property - def local_url(self) -> str: - """The ``http://host:port`` URL that the CLI should use as - ``ANTHROPIC_BASE_URL``. Raises if :meth:`start` has not been - called yet.""" - if self._port is None: - raise RuntimeError("Proxy is not running — call start() first.") - return f"http://{self._bind_host}:{self._port}" - - @property - def target_base_url(self) -> str: - """The upstream URL the proxy is forwarding to.""" - return self._target_base_url - - async def start(self) -> None: - """Bind to a random local port and start serving. - - Cleans up the ``ClientSession`` and the ``AppRunner`` on any - failure during setup so a partially-initialised proxy never - leaves resources dangling (covers the - ``runner.setup() / site.start()`` raise paths in addition to - the explicit bind-failure branches below). - """ - if self._runner is not None: - return # already started - # Use sock_connect + sock_read instead of total so long-lived - # SSE / streaming responses aren't killed after request_timeout. - # total=None means no cumulative limit; sock_read is the per-chunk - # idle timeout (time between data arriving on the socket). - client = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout( - total=None, - sock_connect=self._request_timeout, - sock_read=self._request_timeout, - ) - ) - app = web.Application() - # Catch every method + path so we can also forward GETs - # (the CLI may probe profile / model endpoints). - app.router.add_route("*", "/{tail:.*}", self._handle) - runner = web.AppRunner(app) - runner_setup = False - try: - await runner.setup() - runner_setup = True - site = web.TCPSite(runner, self._bind_host, 0) - await site.start() - server = site._server - if server is None: - raise RuntimeError("Failed to bind compat proxy server.") - sockets = getattr(server, "sockets", None) - if not sockets: - raise RuntimeError("Compat proxy server has no listening sockets.") - self._port = sockets[0].getsockname()[1] - except BaseException: - # Best-effort teardown — swallow secondary errors so the - # caller sees the original exception. - if runner_setup: - try: - await runner.cleanup() - except Exception: # pragma: no cover - cleanup-only path - logger.exception("compat proxy runner cleanup failed") - try: - await client.close() - except Exception: # pragma: no cover - cleanup-only path - logger.exception("compat proxy client close failed") - raise - # Only publish the attributes after everything is wired up so - # ``stop()`` and ``local_url`` observe a consistent state. - self._client = client - self._runner = runner - # Deliberately log only the local bind port — never the - # upstream URL or any derived component. CodeQL's - # `py/clear-text-logging-sensitive-data` taint analysis traces - # everything that originates from a config-supplied URL as - # potentially-sensitive even after parsing, and the upstream - # endpoint is anyway discoverable from the config the operator - # already has access to. The detailed upstream is exposed via - # the ``target_base_url`` property for callers that need it. - logger.info( - "OpenRouter compat proxy listening on %s:%d", - self._bind_host, - self._port, - ) - - async def stop(self) -> None: - """Stop accepting connections and release the port.""" - if self._runner is not None: - await self._runner.cleanup() - self._runner = None - if self._client is not None: - await self._client.close() - self._client = None - self._port = None - - async def __aenter__(self) -> "OpenRouterCompatProxy": - await self.start() - return self - - async def __aexit__(self, exc_type, exc, tb) -> None: - await self.stop() - - async def _handle(self, request: web.Request) -> web.StreamResponse: - """Forward *request* to the upstream after stripping forbidden - features. Streams the upstream response back to the caller - chunk-by-chunk so SSE / streamed responses work.""" - if self._client is None: - raise web.HTTPInternalServerError(reason="proxy client missing") - - # Build the upstream URL. ``request.path_qs`` includes the - # query string verbatim. ``request.path`` for ``/v1/messages`` - # is just ``/v1/messages`` — we strip a leading slash and - # concat with the target base URL. - upstream_path = request.path_qs - if not upstream_path.startswith("/"): - upstream_path = "/" + upstream_path - # Allow the target_base_url to itself contain a path (e.g. - # ``https://openrouter.ai/api/v1``). In that case requests to - # ``/v1/messages`` need to become ``/api/v1/messages``, not - # ``/api/v1/v1/messages``. Strip a leading ``/v1`` from the - # incoming path if the target already ends with ``/v1`` (or - # similar API-version segment). - # Deduplicate API version prefix: if the target URL already - # contains a versioned path segment (e.g. ``/api/v1``) and the - # incoming request path starts with the same segment, strip it - # to avoid ``/api/v1/v1/messages``. - from urllib.parse import urlparse - - target_base = self._target_base_url - target_path = urlparse(target_base).path.rstrip("/") - if target_path and upstream_path.startswith(target_path + "/"): - upstream_path = upstream_path[len(target_path) :] - elif target_path and upstream_path == target_path: - upstream_path = "/" - upstream_url = f"{target_base}{upstream_path}" - - body_bytes = await request.read() - cleaned_body = clean_request_body_bytes(body_bytes) - cleaned_headers = clean_request_headers(dict(request.headers)) - - try: - upstream_response = await self._client.request( - method=request.method, - url=upstream_url, - data=cleaned_body if cleaned_body else None, - headers=cleaned_headers, - allow_redirects=False, - ) - except (aiohttp.ClientError, asyncio.TimeoutError) as e: - # ``aiohttp.ClientTimeout`` raises ``asyncio.TimeoutError`` - # (not ``aiohttp.ClientError``) on hung upstreams, so both - # must be caught here to surface the explicit 502 failure - # mode this proxy guarantees. - # - # Log the detailed error for ops, but return a generic - # message to the caller — exception strings can leak - # internal hostnames, ports, or stack frames (CodeQL - # `py/stack-trace-exposure`). - logger.warning( - "OpenRouter compat proxy upstream error: %s", type(e).__name__ - ) - return web.Response(status=502, text="upstream error") - - # Stream the response back unchanged (apart from hop-by-hop - # header filtering). Use clean_response_headers to preserve - # multi-valued headers like Set-Cookie that dict() would drop. - from multidict import CIMultiDict - - downstream = web.StreamResponse( - status=upstream_response.status, - headers=CIMultiDict(clean_response_headers(upstream_response.headers)), - ) - await downstream.prepare(request) - # Track whether the stream terminated cleanly. A mid-stream - # ``aiohttp.ClientError`` means the upstream died before - # finishing; calling ``write_eof()`` on that partial response - # would signal "complete stream" to the downstream client and - # silently corrupt the body. Skip the EOF on the error path - # so the client's connection is dropped instead, surfacing the - # failure correctly. - cancelled = False - stream_error: aiohttp.ClientError | None = None - try: - async for chunk in upstream_response.content.iter_any(): - await downstream.write(chunk) - except asyncio.CancelledError: - # Never suppress cancellation — since Python 3.8 it's a - # ``BaseException`` subclass precisely so catching - # ``Exception`` won't accidentally swallow it. Release - # the upstream body and re-raise so the asyncio task - # cooperatively unwinds (avoids hanging shutdowns / - # stuck request handlers). - cancelled = True - upstream_response.release() - raise - except aiohttp.ClientError as e: - stream_error = e - logger.warning( - "OpenRouter compat proxy stream interrupted: %s", type(e).__name__ - ) - finally: - if not cancelled: - upstream_response.release() - - if stream_error is not None: - # Do NOT call ``write_eof`` or return the prepared - # ``downstream`` here — aiohttp finalises a returned - # StreamResponse (writing the terminating chunk / - # content-length / EOF) even if we skipped ``write_eof`` - # ourselves, which would signal a clean end of stream to - # the client on top of the truncated body. Instead abort - # the underlying transport directly so the client's - # parser surfaces a ``ClientPayloadError`` / - # ``ServerDisconnectedError`` and the caller can retry / - # surface the failure instead of silently consuming a - # corrupt body. - try: - downstream.force_close() - except Exception: # pragma: no cover - defensive on transport - pass - transport = request.transport - if transport is not None: - try: - transport.abort() - except Exception: # pragma: no cover - defensive on transport - pass - # Re-raise the original stream error so aiohttp treats - # this handler as having failed; the transport is - # already aborted above so the client sees an abrupt - # disconnect either way. - raise stream_error - - await downstream.write_eof() - return downstream diff --git a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py b/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py deleted file mode 100644 index c98711e24f..0000000000 --- a/autogpt_platform/backend/backend/copilot/sdk/openrouter_compat_proxy_test.py +++ /dev/null @@ -1,695 +0,0 @@ -"""Tests for the OpenRouter compatibility proxy. - -The proxy strips two known forbidden patterns from requests so newer -``claude-agent-sdk`` / Claude Code CLI versions can talk to OpenRouter -through the unchanged transport. These tests cover both: - -* the pure stripping helpers (deterministic, no I/O), and -* the end-to-end proxy behaviour against a fake upstream server, so we - catch hop-by-hop header bugs and streaming regressions. - -See ``openrouter_compat_proxy.py`` for the rationale and the upstream -issues being worked around. -""" - -from __future__ import annotations - -import asyncio -import json -from typing import Any - -import aiohttp -import pytest -from aiohttp import web - -from backend.copilot.sdk.openrouter_compat_proxy import ( - _FORBIDDEN_BETA_TOKENS, - _HOP_BY_HOP_HEADERS, - OpenRouterCompatProxy, - clean_request_body_bytes, - clean_request_headers, - strip_forbidden_anthropic_beta_header, - strip_forbidden_betas_from_body, - strip_tool_reference_blocks, -) - -# --------------------------------------------------------------------------- -# strip_tool_reference_blocks -# --------------------------------------------------------------------------- - - -class TestStripToolReferenceBlocks: - """The CLI's built-in ToolSearch tool emits ``tool_reference`` - content blocks in ``tool_result.content``. OpenRouter's stricter - Zod validation rejects them. We drop them entirely — they're - metadata about which tools were searched, not real model-visible - content.""" - - def test_removes_tool_reference_block_at_top_level(self): - block = {"type": "tool_reference", "tool_name": "find_block"} - assert strip_tool_reference_blocks(block) is None - - def test_removes_tool_reference_block_from_list(self): - blocks = [ - {"type": "text", "text": "hello"}, - {"type": "tool_reference", "tool_name": "find_block"}, - {"type": "text", "text": "world"}, - ] - assert strip_tool_reference_blocks(blocks) == [ - {"type": "text", "text": "hello"}, - {"type": "text", "text": "world"}, - ] - - def test_strips_nested_tool_reference_inside_tool_result(self): - # The exact shape PR #12294 root-caused: tool_result.content - # contains the tool_reference block. - request = { - "messages": [ - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tu_1", - "content": [ - {"type": "text", "text": "result text"}, - { - "type": "tool_reference", - "tool_name": "mcp__copilot__find_block", - }, - ], - } - ], - } - ] - } - cleaned = strip_tool_reference_blocks(request) - tool_result_content = cleaned["messages"][0]["content"][0]["content"] - assert tool_result_content == [{"type": "text", "text": "result text"}] - - def test_preserves_unrelated_payloads(self): - payload = { - "model": "claude-opus-4.6", - "messages": [{"role": "user", "content": "hi"}], - "temperature": 0.7, - } - assert strip_tool_reference_blocks(payload) == payload - - def test_handles_empty_and_primitive_inputs(self): - assert strip_tool_reference_blocks({}) == {} - assert strip_tool_reference_blocks([]) == [] - assert strip_tool_reference_blocks("plain string") == "plain string" - assert strip_tool_reference_blocks(42) == 42 - assert strip_tool_reference_blocks(None) is None - - def test_removes_dict_valued_tool_reference_child_entirely(self): - # Regression guard: when a tool_reference dict is assigned to - # a key rather than listed, the helper used to rewrite it to - # `null` (leaving the parent key with a None value). That is - # still schema-invalid upstream — remove the key entirely. - payload = { - "wrapper": {"type": "tool_reference", "tool_name": "find_block"}, - "keep": "value", - } - cleaned = strip_tool_reference_blocks(payload) - assert "wrapper" not in cleaned - assert cleaned["keep"] == "value" - - def test_preserves_genuine_none_values_on_non_dict_children(self): - payload = {"explicit_null": None, "text": "ok"} - cleaned = strip_tool_reference_blocks(payload) - assert cleaned == {"explicit_null": None, "text": "ok"} - - -# --------------------------------------------------------------------------- -# strip_forbidden_betas_from_body -# --------------------------------------------------------------------------- - - -class TestStripForbiddenBetasFromBody: - """OpenRouter rejects ``context-management-2025-06-27`` in the - request body's ``betas`` array.""" - - def test_removes_forbidden_token_keeps_others(self): - body = { - "model": "claude-opus-4.6", - "betas": [ - "context-management-2025-06-27", - "fine-grained-tool-streaming-2025", - ], - } - cleaned = strip_forbidden_betas_from_body(body) - assert cleaned["betas"] == ["fine-grained-tool-streaming-2025"] - - def test_removes_betas_field_entirely_when_only_forbidden(self): - body = {"model": "x", "betas": ["context-management-2025-06-27"]} - cleaned = strip_forbidden_betas_from_body(body) - assert "betas" not in cleaned - - def test_no_op_when_no_betas_field(self): - body = {"model": "x"} - assert strip_forbidden_betas_from_body(body) == {"model": "x"} - - def test_no_op_on_non_dict(self): - assert strip_forbidden_betas_from_body([1, 2, 3]) == [1, 2, 3] - assert strip_forbidden_betas_from_body("plain") == "plain" - - def test_all_forbidden_tokens_constants_are_recognized(self): - for forbidden in _FORBIDDEN_BETA_TOKENS: - body = {"betas": [forbidden, "other"]} - cleaned = strip_forbidden_betas_from_body(body) - assert forbidden not in cleaned["betas"] - - -# --------------------------------------------------------------------------- -# strip_forbidden_anthropic_beta_header -# --------------------------------------------------------------------------- - - -class TestStripForbiddenAnthropicBetaHeader: - def test_removes_forbidden_token_keeps_others(self): - value = "fine-grained-tool-streaming-2025, context-management-2025-06-27, other-beta" - result = strip_forbidden_anthropic_beta_header(value) - assert result == "fine-grained-tool-streaming-2025, other-beta" - - def test_returns_none_when_only_forbidden_token_present(self): - assert ( - strip_forbidden_anthropic_beta_header("context-management-2025-06-27") - is None - ) - - def test_passes_through_clean_header(self): - assert strip_forbidden_anthropic_beta_header("foo, bar") == "foo, bar" - - def test_handles_empty_and_none_input(self): - assert strip_forbidden_anthropic_beta_header("") == "" - assert strip_forbidden_anthropic_beta_header(None) is None - - def test_handles_extra_whitespace(self): - value = " context-management-2025-06-27 , fine-grained " - result = strip_forbidden_anthropic_beta_header(value) - assert result == "fine-grained" - - -# --------------------------------------------------------------------------- -# clean_request_body_bytes — combined body-level cleanup -# --------------------------------------------------------------------------- - - -class TestCleanRequestBodyBytes: - def test_strips_both_patterns_in_one_pass(self): - body = { - "model": "claude-opus-4.6", - "betas": ["context-management-2025-06-27"], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tu_1", - "content": [ - {"type": "tool_reference", "tool_name": "find"}, - {"type": "text", "text": "ok"}, - ], - } - ], - } - ], - } - cleaned_bytes = clean_request_body_bytes(json.dumps(body).encode("utf-8")) - cleaned = json.loads(cleaned_bytes.decode("utf-8")) - assert "betas" not in cleaned # only forbidden token, dropped - tool_result_content = cleaned["messages"][0]["content"][0]["content"] - assert tool_result_content == [{"type": "text", "text": "ok"}] - - def test_passes_through_non_json_body(self): - garbage = b"\xff\xfe not json at all" - assert clean_request_body_bytes(garbage) == garbage - - def test_passes_through_empty_body(self): - assert clean_request_body_bytes(b"") == b"" - - -# --------------------------------------------------------------------------- -# clean_request_headers — hop-by-hop + anthropic-beta cleanup -# --------------------------------------------------------------------------- - - -class TestCleanRequestHeaders: - def test_drops_hop_by_hop_headers(self): - headers = { - "Host": "example.com", - "Connection": "keep-alive", - "Content-Length": "42", - "Authorization": "Bearer xxx", - "Content-Type": "application/json", - } - cleaned = clean_request_headers(headers) - assert "Host" not in cleaned - assert "Connection" not in cleaned - assert "Content-Length" not in cleaned - assert cleaned["Authorization"] == "Bearer xxx" - assert cleaned["Content-Type"] == "application/json" - - def test_strips_forbidden_token_from_anthropic_beta_header(self): - headers = { - "anthropic-beta": "context-management-2025-06-27, other-beta", - "Authorization": "Bearer x", - } - cleaned = clean_request_headers(headers) - assert cleaned["anthropic-beta"] == "other-beta" - - def test_drops_anthropic_beta_header_when_only_forbidden(self): - headers = {"anthropic-beta": "context-management-2025-06-27"} - cleaned = clean_request_headers(headers) - assert "anthropic-beta" not in cleaned - - def test_hop_by_hop_set_completeness(self): - # Sanity check: if upstream removes hop-by-hop headers from - # this set we want to know — keep the canonical RFC 7230 list. - for required in ( - "connection", - "transfer-encoding", - "host", - "trailer", - "trailers", - ): - assert required in _HOP_BY_HOP_HEADERS - - def test_drops_headers_listed_in_connection_field(self): - # Per RFC 7230 §6.1 intermediaries must also drop every - # header name listed in the incoming Connection field value - # (extension hop-by-hop headers signalled per-connection). - headers = { - "Connection": "X-Custom-Hop, Upgrade", - "X-Custom-Hop": "secret-extension", - "Authorization": "Bearer x", - "X-Keep": "ok", - } - cleaned = clean_request_headers(headers) - assert "X-Custom-Hop" not in cleaned - # Upgrade is a static hop-by-hop header; Connection itself is - # also dropped; the rest pass through. - assert "Connection" not in cleaned - assert cleaned["Authorization"] == "Bearer x" - assert cleaned["X-Keep"] == "ok" - - def test_connection_token_matching_is_case_insensitive(self): - headers = { - "Connection": "x-hop-HEADER", - "X-Hop-Header": "drop-me", - "X-Keep": "ok", - } - cleaned = clean_request_headers(headers) - assert "X-Hop-Header" not in cleaned - assert cleaned["X-Keep"] == "ok" - - -# --------------------------------------------------------------------------- -# End-to-end: real proxy + fake upstream -# --------------------------------------------------------------------------- - - -class _FakeUpstream: - """Tiny aiohttp app that records every request the proxy forwards - so the test can assert on the cleaned payloads.""" - - def __init__(self) -> None: - self.captured: list[dict[str, Any]] = [] - self._runner: web.AppRunner | None = None - self.port: int = 0 - - async def start(self) -> str: - async def handler(request: web.Request) -> web.StreamResponse: - body = await request.text() - self.captured.append( - { - "method": request.method, - "path": request.path_qs, - "headers": {k: v for k, v in request.headers.items()}, - "body": body, - } - ) - # Return a minimal JSON success response so the proxy has - # something to stream back. - return web.json_response({"ok": True, "echoed": body}) - - app = web.Application() - app.router.add_route("*", "/{tail:.*}", handler) - self._runner = web.AppRunner(app) - await self._runner.setup() - site = web.TCPSite(self._runner, "127.0.0.1", 0) - await site.start() - server = site._server - assert server is not None - sockets = getattr(server, "sockets", None) - assert sockets is not None - self.port = sockets[0].getsockname()[1] - return f"http://127.0.0.1:{self.port}" - - async def stop(self) -> None: - if self._runner is not None: - await self._runner.cleanup() - self._runner = None - - -@pytest.mark.asyncio -async def test_proxy_strips_tool_reference_block_end_to_end(): - upstream = _FakeUpstream() - upstream_url = await upstream.start() - proxy = OpenRouterCompatProxy(target_base_url=upstream_url) - await proxy.start() - try: - body = { - "model": "claude-opus-4.6", - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": "hi"}, - { - "type": "tool_reference", - "tool_name": "mcp__copilot__find_block", - }, - ], - } - ], - } - async with aiohttp.ClientSession() as client: - async with client.post( - f"{proxy.local_url}/v1/messages", - json=body, - headers={"Authorization": "Bearer test"}, - ) as resp: - assert resp.status == 200 - await resp.read() - finally: - await proxy.stop() - await upstream.stop() - - assert len(upstream.captured) == 1 - forwarded = json.loads(upstream.captured[0]["body"]) - # The tool_reference block must NOT be in the upstream-visible body. - assert '"tool_reference"' not in upstream.captured[0]["body"] - assert forwarded["messages"][0]["content"] == [{"type": "text", "text": "hi"}] - - -@pytest.mark.asyncio -async def test_proxy_strips_context_management_beta_header_end_to_end(): - upstream = _FakeUpstream() - upstream_url = await upstream.start() - proxy = OpenRouterCompatProxy(target_base_url=upstream_url) - await proxy.start() - try: - async with aiohttp.ClientSession() as client: - async with client.post( - f"{proxy.local_url}/v1/messages", - json={"model": "x", "messages": []}, - headers={ - "Authorization": "Bearer test", - "anthropic-beta": "context-management-2025-06-27, other-beta", - }, - ) as resp: - assert resp.status == 200 - await resp.read() - finally: - await proxy.stop() - await upstream.stop() - - forwarded_headers = upstream.captured[0]["headers"] - # Header is rewritten to remove only the forbidden token, keeping the rest. - assert any( - k.lower() == "anthropic-beta" and v == "other-beta" - for k, v in forwarded_headers.items() - ) - - -@pytest.mark.asyncio -async def test_proxy_strips_betas_from_request_body_end_to_end(): - upstream = _FakeUpstream() - upstream_url = await upstream.start() - proxy = OpenRouterCompatProxy(target_base_url=upstream_url) - await proxy.start() - try: - body = { - "model": "x", - "betas": [ - "context-management-2025-06-27", - "fine-grained-tool-streaming-2025", - ], - "messages": [], - } - async with aiohttp.ClientSession() as client: - async with client.post( - f"{proxy.local_url}/v1/messages", - json=body, - ) as resp: - assert resp.status == 200 - await resp.read() - finally: - await proxy.stop() - await upstream.stop() - - forwarded = json.loads(upstream.captured[0]["body"]) - # Only the surviving beta should be present. - assert forwarded["betas"] == ["fine-grained-tool-streaming-2025"] - - -@pytest.mark.asyncio -async def test_proxy_passes_through_clean_request_unchanged(): - """The proxy must be a no-op for requests that don't contain any of - the forbidden patterns — no other rewriting allowed.""" - upstream = _FakeUpstream() - upstream_url = await upstream.start() - proxy = OpenRouterCompatProxy(target_base_url=upstream_url) - await proxy.start() - try: - body = { - "model": "claude-opus-4.6", - "messages": [{"role": "user", "content": "hello"}], - "temperature": 0.7, - } - async with aiohttp.ClientSession() as client: - async with client.post( - f"{proxy.local_url}/v1/messages", - json=body, - headers={ - "Authorization": "Bearer test", - "Content-Type": "application/json", - }, - ) as resp: - assert resp.status == 200 - await resp.read() - finally: - await proxy.stop() - await upstream.stop() - - forwarded = json.loads(upstream.captured[0]["body"]) - assert forwarded == body - - -@pytest.mark.asyncio -async def test_proxy_returns_502_on_upstream_failure(): - """If the upstream is unreachable the proxy must return a clear - 502, not silently hang. - - Note: the outer ``client.post`` talks to the *proxy* on localhost, - not to the dead upstream directly. The proxy is the thing under - test, so it should always respond with a 502 — we must NOT - swallow ``aiohttp.ClientError`` / ``asyncio.TimeoutError`` on the - outer call, because that would mask a proxy crash and turn the - assertion into a false positive. Let any such exception fail the - test. - """ - proxy = OpenRouterCompatProxy( - target_base_url="http://127.0.0.1:1", # nothing listening - ) - await proxy.start() - try: - async with aiohttp.ClientSession() as client: - async with client.post( - f"{proxy.local_url}/v1/messages", - json={"model": "x"}, - timeout=aiohttp.ClientTimeout(total=10), - ) as resp: - assert resp.status == 502 - text = await resp.text() - # Generic error message — no internal hostname leaked. - assert "upstream error" in text - finally: - await proxy.stop() - - -@pytest.mark.asyncio -async def test_proxy_returns_502_on_upstream_timeout(): - """``aiohttp.ClientTimeout`` raises ``asyncio.TimeoutError`` (not - ``aiohttp.ClientError``), which previously escaped the except - block and surfaced as a 500. This regression-guards the 502 - contract for hung upstreams.""" - - class _HangingUpstream: - """Upstream that accepts the request but never finishes the - response body, forcing the proxy's client timeout to fire.""" - - def __init__(self) -> None: - self._runner: web.AppRunner | None = None - self.port: int = 0 - - async def start(self) -> str: - async def handler(request: web.Request) -> web.StreamResponse: - # Hold the response open longer than the proxy's - # client timeout so aiohttp raises TimeoutError on - # the proxy side. - await asyncio.sleep(30) - return web.Response(status=200) - - app = web.Application() - app.router.add_route("*", "/{tail:.*}", handler) - self._runner = web.AppRunner(app) - await self._runner.setup() - site = web.TCPSite(self._runner, "127.0.0.1", 0) - await site.start() - server = site._server - assert server is not None - sockets = getattr(server, "sockets", None) - assert sockets is not None - self.port = sockets[0].getsockname()[1] - return f"http://127.0.0.1:{self.port}" - - async def stop(self) -> None: - if self._runner is not None: - await self._runner.cleanup() - self._runner = None - - upstream = _HangingUpstream() - upstream_url = await upstream.start() - # Short proxy timeout so the test finishes quickly. - proxy = OpenRouterCompatProxy(target_base_url=upstream_url, request_timeout=0.5) - await proxy.start() - try: - async with aiohttp.ClientSession() as client: - async with client.post( - f"{proxy.local_url}/v1/messages", - json={"model": "x"}, - timeout=aiohttp.ClientTimeout(total=10), - ) as resp: - assert resp.status == 502 - text = await resp.text() - # Generic error message — no internal hostname leaked. - assert "upstream error" in text - finally: - await proxy.stop() - await upstream.stop() - - -@pytest.mark.asyncio -async def test_proxy_does_not_signal_clean_eof_on_mid_stream_error(): - """Regression guard: if the upstream stream dies mid-body, the - proxy must NOT call ``write_eof()`` — that would mark the - downstream response as a complete, valid stream even though the - client only saw a truncated body. Instead the proxy drops the - connection so the client's parser surfaces a transport error. - - We simulate the failure with a raw asyncio TCP server that - sends a chunked-encoding response header plus one partial chunk - and then hard-closes the socket — this is the one failure mode - aiohttp's ``iter_any()`` reliably surfaces as an - ``aiohttp.ClientError`` rather than an ordinary clean EOF. - """ - - class _TruncatingUpstream: - """Raw TCP server that sends a partial chunked body then - closes the socket without writing the terminating chunk.""" - - def __init__(self) -> None: - self._server: asyncio.base_events.Server | None = None - self.port: int = 0 - - async def start(self) -> str: - async def handle_conn( - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - ) -> None: - try: - # Read and discard the request until the blank - # line — we don't care what the proxy sends. - while True: - line = await reader.readline() - if not line or line == b"\r\n": - break - # Chunked response with one partial chunk. - writer.write( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: application/octet-stream\r\n" - b"Transfer-Encoding: chunked\r\n" - b"Connection: close\r\n" - b"\r\n" - # One chunk, size 8, content "partial-". - b"8\r\n" - b"partial-\r\n" - # Deliberately DO NOT send the terminating - # "0\r\n\r\n" — this is the mid-stream - # truncation we're testing. - ) - await writer.drain() - finally: - # Hard-close the socket so the proxy's - # iter_any() sees an abrupt end-of-stream. - try: - writer.transport.abort() - except Exception: - pass - - self._server = await asyncio.start_server(handle_conn, "127.0.0.1", 0) - sockets = self._server.sockets - assert sockets is not None - self.port = sockets[0].getsockname()[1] - return f"http://127.0.0.1:{self.port}" - - async def stop(self) -> None: - if self._server is not None: - self._server.close() - await self._server.wait_closed() - self._server = None - - upstream = _TruncatingUpstream() - upstream_url = await upstream.start() - proxy = OpenRouterCompatProxy(target_base_url=upstream_url, request_timeout=5.0) - await proxy.start() - try: - async with aiohttp.ClientSession() as client: - client_error: Exception | None = None - try: - async with client.post( - f"{proxy.local_url}/v1/messages", - json={"model": "x"}, - timeout=aiohttp.ClientTimeout(total=10), - ) as resp: - # The client should see either an error raising - # here or a truncated body followed by a - # transport-level failure on read — both surface - # the truncation instead of silently reporting - # success. - await resp.read() - except ( - aiohttp.ClientPayloadError, - aiohttp.ClientConnectionError, - aiohttp.ServerDisconnectedError, - ) as e: - client_error = e - assert client_error is not None, ( - "Proxy silently consumed an upstream mid-stream " - "failure and returned a clean EOF to the client — " - "regression in the stream-error path." - ) - finally: - await proxy.stop() - await upstream.stop() - - -@pytest.mark.asyncio -async def test_proxy_local_url_raises_before_start(): - proxy = OpenRouterCompatProxy(target_base_url="http://example.com") - with pytest.raises(RuntimeError): - _ = proxy.local_url diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index 835bd82603..eba8c843c5 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -202,22 +202,11 @@ def test_sdk_exports_hook_event_type(hook_event: str): # OpenRouter compatibility — bundled CLI version pin # --------------------------------------------------------------------------- # -# We're stuck on ``claude-agent-sdk==0.1.45`` (bundled CLI ``2.1.63``) -# because every version above introduces a 400 against OpenRouter: -# -# 1. CLI ``2.1.69`` (= SDK ``0.1.46``) shipped a `tool_reference` content -# block in `tool_result.content` that OpenRouter's stricter Zod -# validation rejects. See PR -# https://github.com/Significant-Gravitas/AutoGPT/pull/12294 for the -# forensic write-up that originally pinned us. CLI ``2.1.70`` added -# proxy detection that *should* disable the offending block, but two -# later attempts (Dependabot bumps to 0.1.55 / 0.1.56) still failed. -# -# 2. A second regression — the ``context-management-2025-06-27`` beta -# header — appeared in some CLI version after ``2.1.91``. Tracked -# upstream at -# https://github.com/anthropics/claude-agent-sdk-python/issues/789 -# (still open at the time of writing, no upstream PR yet). +# Newer ``claude-agent-sdk`` versions bundle CLI binaries that send +# features incompatible with OpenRouter (``tool_reference`` content +# blocks, ``context-management-2025-06-27`` beta). We neutralise these +# at runtime by injecting ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` +# into the CLI subprocess env (see ``service.py``). # # This test is the cheapest possible regression guard: it pins the # bundled CLI to a known-good version. If anyone bumps @@ -225,89 +214,39 @@ def test_sdk_exports_hook_event_type(hook_event: str): # ``_cli_version.py`` will change and this test will fail with a clear # message that points the next person at the OpenRouter compat issue # instead of letting them silently re-break production. -# -# Workaround for actually upgrading: set the -# ``claude_agent_cli_path`` config option (or the matching env var) to -# point at a separately-installed Claude Code CLI binary at a known-good -# version, so the SDK Python API surface and the CLI binary version can -# be picked independently. -# CLI versions verified to work against OpenRouter directly (no compat -# proxy required) — bisected via the reproduction test in -# `cli_openrouter_compat_test.py`. Bundled CLI versions outside this -# set are still allowed but ONLY when the compat proxy is enabled (see -# the second known-good set below + the test below). -_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT: frozenset[str] = frozenset( +# CLI versions verified to work against OpenRouter when the +# ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` env var is set -- +# bisected via the reproduction test in ``cli_openrouter_compat_test.py``. +_KNOWN_GOOD_BUNDLED_CLI_VERSIONS: frozenset[str] = frozenset( { - "2.1.63", # claude-agent-sdk 0.1.45 — original pin from PR #12294. - "2.1.70", # claude-agent-sdk 0.1.47 — first version with the + "2.1.63", # claude-agent-sdk 0.1.45 -- original pin from PR #12294. + "2.1.70", # claude-agent-sdk 0.1.47 -- first version with the # tool_reference proxy detection fix; bisect-verified # OpenRouter-safe in #12742. + "2.1.97", # claude-agent-sdk 0.1.58 -- works with the + # CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 env var. } ) -# CLI versions verified to work against OpenRouter ONLY when the -# in-process `openrouter_compat_proxy` is enabled (which strips the -# `tool_reference` content blocks and `context-management-2025-06-27` -# beta from outgoing requests). Without the proxy these CLI versions -# trip OpenRouter's stricter validation and return 400. -_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY: frozenset[str] = frozenset( - { - "2.1.97", # claude-agent-sdk 0.1.58 — needs `claude_agent_use_compat_proxy=True` - # due to the upstream regression in - # anthropics/claude-agent-sdk-python#789. - } -) - -# Aggregate set used by the assertion below — the test allows EITHER -# a directly-known-good CLI OR a proxy-known-good CLI when the proxy -# is enabled in the active config. -_KNOWN_GOOD_BUNDLED_CLI_VERSIONS: frozenset[str] = ( - _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT | _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY -) - def test_bundled_cli_version_is_known_good_against_openrouter(): """Pin the bundled CLI version so accidental SDK bumps cause a loud, fast failure with a pointer to the OpenRouter compatibility issue. - - A CLI version that's only safe via the compat proxy is allowed only - when ``ChatConfig.claude_agent_use_compat_proxy`` is enabled. """ from claude_agent_sdk._cli_version import __cli_version__ - from backend.copilot.config import ChatConfig - - cfg = ChatConfig() - proxy_enabled = cfg.claude_agent_use_compat_proxy - - if __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT: - return # safe with or without the proxy - - if __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY: - assert proxy_enabled, ( - f"Bundled Claude Code CLI version {__cli_version__!r} is only " - "OpenRouter-safe when `claude_agent_use_compat_proxy` is " - "enabled, but the active ChatConfig has the proxy disabled. " - "Either set `COPILOT__CLAUDE_AGENT_USE_COMPAT_PROXY=true` or " - "downgrade `claude-agent-sdk` to a version whose bundled CLI " - f"is in {sorted(_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT)!r}. " - "See https://github.com/anthropics/claude-agent-sdk-python/issues/789." - ) - return - - raise AssertionError( + assert __cli_version__ in _KNOWN_GOOD_BUNDLED_CLI_VERSIONS, ( f"Bundled Claude Code CLI version is {__cli_version__!r}, which is " - f"not in any OpenRouter-known-good set " + f"not in the OpenRouter-known-good set " f"({sorted(_KNOWN_GOOD_BUNDLED_CLI_VERSIONS)!r}). " "If you intentionally bumped `claude-agent-sdk`, verify the new " "bundled CLI works with OpenRouter against the reproduction test " - "in `cli_openrouter_compat_test.py`, then add the new CLI version " - "to either `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_DIRECT` (works " - "without the proxy) or `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS_VIA_PROXY` " - "(works only with `claude_agent_use_compat_proxy=true`). If you " - "cannot make the bundled CLI work either way, set " - "`claude_agent_cli_path` to a known-good binary instead. See " + "in `cli_openrouter_compat_test.py` (with " + "`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`), then add the new " + "CLI version to `_KNOWN_GOOD_BUNDLED_CLI_VERSIONS`. If the env " + "var is not sufficient, set `claude_agent_cli_path` to a " + "known-good binary instead. See " "https://github.com/anthropics/claude-agent-sdk-python/issues/789 " "and https://github.com/Significant-Gravitas/AutoGPT/pull/12294." ) diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 96dfb67e8e..d8b164091e 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -1980,13 +1980,6 @@ async def stream_chat_completion_sdk( transcript_content: str = "" state: _RetryState | None = None - # OpenRouter compat proxy — started inside the try and stopped in finally - # when ``ChatConfig.claude_agent_use_compat_proxy`` is enabled. The proxy - # rewrites outgoing CLI requests to strip ``tool_reference`` content - # blocks and the ``context-management-2025-06-27`` beta so the latest - # SDK / CLI versions stop tripping OpenRouter's validation. - _compat_proxy: Any = None # OpenRouterCompatProxy | None — lazy import - # Token usage accumulators — populated from ResultMessage at end of turn turn_prompt_tokens = 0 # uncached input tokens only turn_completion_tokens = 0 @@ -2249,96 +2242,14 @@ async def stream_chat_completion_sdk( if sdk_model: sdk_options_kwargs["model"] = sdk_model - # OpenRouter compatibility proxy — started here so its local URL - # can be injected into the CLI subprocess env BEFORE the env dict - # is passed to ``ClaudeAgentOptions``. When this flag is on we - # transparently rewrite outgoing CLI requests via the proxy - # (stripping ``tool_reference`` blocks and the - # ``context-management-2025-06-27`` beta) so newer SDK / CLI - # versions can talk to OpenRouter without their stricter - # validation rejecting the request. - if config.claude_agent_use_compat_proxy: - # Only start the compat proxy when there's already an - # explicit Anthropic-compatible upstream to forward to. - # Otherwise we'd be silently routing direct Anthropic / - # Claude Code subscription sessions through OpenRouter, - # which would break auth and change providers without - # operator consent. The explicit upstream can come from: - # - # 1. ``sdk_env['ANTHROPIC_BASE_URL']`` — caller override; - # 2. the process env — lowest-precedence host override; - # 3. ``ChatConfig.openrouter_active`` — OpenRouter is - # configured as the session's routing provider (i.e. - # the only case in which falling back to - # ``OPENROUTER_BASE_URL`` is intentional). - # - # When none of the above hold, log a warning and leave - # the CLI to talk to Anthropic directly as usual — the - # feature is opt-in and documented as "OpenRouter - # compatibility", so quietly no-oping on direct-Anthropic - # sessions is the safe default. - # Claude Code subscription mode intentionally sets - # ``sdk_env['ANTHROPIC_BASE_URL'] = ""`` to *disable* any - # base-URL override and keep the CLI talking to Anthropic - # directly. Treat an explicit empty string as a hard - # "no-proxy" signal so we never silently start the proxy - # against a host-wide ``ANTHROPIC_BASE_URL`` or fall back - # to OpenRouter when the caller has opted out. - sdk_env_map = sdk_env or {} - explicit_sdk_env = "ANTHROPIC_BASE_URL" in sdk_env_map - sdk_env_value = ( - sdk_env_map["ANTHROPIC_BASE_URL"] if explicit_sdk_env else None - ) - if explicit_sdk_env and not sdk_env_value: - # Empty string from sdk_env → subscription mode opt-out. - target_base_url: str | None = None - explicit_opt_out = True - else: - target_base_url = sdk_env_value or os.environ.get("ANTHROPIC_BASE_URL") - explicit_opt_out = False - # Only fall back to OpenRouter when the session actually - # has no base-URL plumbing of its own AND OpenRouter is - # the active routing provider AND the caller hasn't - # explicitly opted out via an empty sdk_env override. - if ( - not target_base_url - and not explicit_opt_out - and config.openrouter_active - ): - from backend.util.clients import OPENROUTER_BASE_URL - - target_base_url = OPENROUTER_BASE_URL - - if target_base_url: - from backend.copilot.sdk.openrouter_compat_proxy import ( - OpenRouterCompatProxy, - ) - - _compat_proxy = OpenRouterCompatProxy(target_base_url=target_base_url) - await _compat_proxy.start() - # Inject the proxy URL into the SDK env so the spawned - # CLI subprocess uses the proxy as its Anthropic - # endpoint. - if sdk_env is None: - sdk_env = {} - sdk_env["ANTHROPIC_BASE_URL"] = _compat_proxy.local_url - # Log only the local bind URL — upstream is redacted - # to match the taint-analysis guidance applied in - # ``openrouter_compat_proxy.start``. - logger.info( - "%s OpenRouter compat proxy active (listening on %s)", - log_prefix, - _compat_proxy.local_url, - ) - else: - logger.warning( - "%s claude_agent_use_compat_proxy is enabled but no " - "Anthropic-compatible upstream is configured for this " - "session (no ANTHROPIC_BASE_URL override and " - "openrouter_active is False); skipping proxy startup " - "so the CLI keeps talking to Anthropic directly.", - log_prefix, - ) + # Tell the CLI to strip experimental betas (e.g. + # ``context-management-2025-06-27``) and ``tool_reference`` + # content blocks so newer SDK / CLI versions work with + # OpenRouter's stricter validation. This single env var + # replaces the old in-process compat proxy. + if sdk_env is None: + sdk_env = {} + sdk_env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1" if sdk_env: sdk_options_kwargs["env"] = sdk_env @@ -3012,18 +2923,5 @@ async def stream_chat_completion_sdk( except Exception: logger.warning("%s SDK cleanup failed", log_prefix, exc_info=True) finally: - # Tear down the OpenRouter compat proxy if it was started for - # this session — releases the bound port and the aiohttp - # client. Wrapped so a stop failure can never block the - # downstream lock release. - if _compat_proxy is not None: - try: - await _compat_proxy.stop() - except Exception: - logger.warning( - "%s OpenRouter compat proxy stop failed", - log_prefix, - exc_info=True, - ) # Release stream lock to allow new streams for this session await lock.release() diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml index 08b1d5f1bc..ea81390d81 100644 --- a/autogpt_platform/backend/pyproject.toml +++ b/autogpt_platform/backend/pyproject.toml @@ -18,7 +18,7 @@ apscheduler = "^3.11.1" autogpt-libs = { path = "../autogpt_libs", develop = true } bleach = { extras = ["css"], version = "^6.2.0" } cachetools = "^5.5.0" -claude-agent-sdk = "0.1.58" # latest stable; bundled CLI 2.1.97 ships the broken context-management beta and REQUIRES the openrouter_compat_proxy. See sdk_compat_test.py. +claude-agent-sdk = "0.1.58" # latest stable; bundled CLI 2.1.97 -- CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 env var strips the broken context-management beta. See sdk_compat_test.py. click = "^8.2.0" cryptography = "^46.0" discord-py = "^2.5.2" From a35e9a2b4c535b613afb7da39f83b104560ff622 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 11:10:48 +0000 Subject: [PATCH 24/34] fix(backend): fix CI failures from proxy removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli_path validation: only check os.access(X_OK) when path exists (tests use non-existent paths to verify env var resolution) - Mark bare CLI test as xfail since CLI 2.1.97 sends the beta header without the env var — the env var test is the real regression guard --- .../backend/backend/copilot/config.py | 6 ++--- .../copilot/sdk/cli_openrouter_compat_test.py | 24 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index a26b105347..bd1acbce83 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -326,10 +326,10 @@ class ChatConfig(BaseSettings): v = os.getenv("CHAT_CLAUDE_AGENT_CLI_PATH") if not v: v = os.getenv("CLAUDE_AGENT_CLI_PATH") - if v and not os.access(v, os.X_OK): + if v and os.path.exists(v) and not os.access(v, os.X_OK): raise ValueError( - f"claude_agent_cli_path '{v}' is not an executable file. " - "Check the path and file permissions." + f"claude_agent_cli_path '{v}' exists but is not executable. " + "Check file permissions." ) return v diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 3b20cd2b68..7605c39172 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -455,18 +455,20 @@ def _assert_no_forbidden_patterns( @pytest.mark.asyncio -async def test_cli_does_not_send_openrouter_incompatible_features(): - """End-to-end OpenRouter compatibility reproduction (bare CLI path). +@pytest.mark.xfail( + reason="CLI 2.1.97 (SDK 0.1.58) sends context-management beta without " + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. This is expected — the env " + "var guard in test_disable_experimental_betas_env_var_strips_headers " + "is the real regression test.", + strict=False, +) +async def test_bare_cli_does_not_send_openrouter_incompatible_features(): + """Bare CLI reproduction (no env var workaround). - Spawns the bundled (or overridden) Claude Code CLI against a fake - Anthropic API server, captures every request body it sends, and - asserts that none of them contain the two known OpenRouter-breaking - features. - - On a clean SDK pin (0.1.45 or 0.1.47, bundled CLI 2.1.63 or 2.1.70) - this passes naturally. On a broken pin (0.1.55+, bundled CLI 2.1.91+) - it fails — that failure IS the bisect signal we use to verify which - SDK versions need the workaround. + Documents whether the bundled CLI sends OpenRouter-incompatible + features without the CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS env var. + On SDK 0.1.58 (CLI 2.1.97) this is expected to fail — the env var + test above is the actual regression guard. """ returncode, _stdout, stderr, captured = await _run_reproduction() _assert_no_forbidden_patterns(captured, returncode, stderr) From 2704e43d42d82fc8db21ca863844809d4be04576 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 11:17:56 +0000 Subject: [PATCH 25/34] fix(backend): move CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS into build_sdk_env Moves the env var injection from service.py into build_sdk_env() in env.py so all callers (including orchestrator.py) get it automatically. Also changes xfail(strict=False) to strict=True so CI fails if the upstream fix lands and we can remove the workaround. --- .../backend/copilot/sdk/cli_openrouter_compat_test.py | 2 +- autogpt_platform/backend/backend/copilot/sdk/env.py | 3 +++ autogpt_platform/backend/backend/copilot/sdk/service.py | 9 --------- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 7605c39172..20c40cc850 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -460,7 +460,7 @@ def _assert_no_forbidden_patterns( "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. This is expected — the env " "var guard in test_disable_experimental_betas_env_var_strips_headers " "is the real regression test.", - strict=False, + strict=True, ) async def test_bare_cli_does_not_send_openrouter_incompatible_features(): """Bare CLI reproduction (no env var workaround). diff --git a/autogpt_platform/backend/backend/copilot/sdk/env.py b/autogpt_platform/backend/backend/copilot/sdk/env.py index 27470c9d05..d8d1561eea 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/env.py +++ b/autogpt_platform/backend/backend/copilot/sdk/env.py @@ -96,5 +96,8 @@ def build_sdk_env( env["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] = "1" env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1" env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + # Strip Anthropic-specific beta headers (e.g. context-management-2025-06-27) + # that OpenRouter rejects. Safe for all modes — direct Anthropic ignores it. + env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1" return env diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index d8b164091e..35b87cd40c 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2242,15 +2242,6 @@ async def stream_chat_completion_sdk( if sdk_model: sdk_options_kwargs["model"] = sdk_model - # Tell the CLI to strip experimental betas (e.g. - # ``context-management-2025-06-27``) and ``tool_reference`` - # content blocks so newer SDK / CLI versions work with - # OpenRouter's stricter validation. This single env var - # replaces the old in-process compat proxy. - if sdk_env is None: - sdk_env = {} - sdk_env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1" - if sdk_env: sdk_options_kwargs["env"] = sdk_env if use_resume and resume_file: From 2c2fadba4780531bfb635bd43d9c59d3a9def920 Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 12:04:15 +0000 Subject: [PATCH 26/34] fix(backend): add env var test coverage and fix stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS assertion in env_test.py - Fix stale references to service.py → build_sdk_env() in env.py --- .../backend/backend/copilot/sdk/cli_openrouter_compat_test.py | 4 ++-- autogpt_platform/backend/backend/copilot/sdk/env_test.py | 2 ++ .../backend/backend/copilot/sdk/sdk_compat_test.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 20c40cc850..64bdf5f656 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -482,8 +482,8 @@ async def test_disable_experimental_betas_env_var_strips_headers(): OpenRouter). This is the main regression guard: the env var is injected by - ``service.py`` into every CLI subprocess so newer SDK / CLI versions - work with OpenRouter without any proxy. + ``build_sdk_env()`` in ``env.py`` into every CLI subprocess so newer + SDK / CLI versions work with OpenRouter without any proxy. """ returncode, _stdout, stderr, captured = await _run_reproduction( extra_env={"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"}, diff --git a/autogpt_platform/backend/backend/copilot/sdk/env_test.py b/autogpt_platform/backend/backend/copilot/sdk/env_test.py index e387499816..cfc1fd3e3f 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/env_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/env_test.py @@ -123,6 +123,8 @@ class TestBuildSdkEnvOpenRouter: assert result["ANTHROPIC_AUTH_TOKEN"] == "sk-or-test-key" assert result["ANTHROPIC_API_KEY"] == "" assert "ANTHROPIC_CUSTOM_HEADERS" not in result + # OpenRouter compat: env var must always be present + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" def test_strips_trailing_v1(self): """The /v1 suffix is stripped from the base URL.""" diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index eba8c843c5..b5ed5004b6 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -206,7 +206,7 @@ def test_sdk_exports_hook_event_type(hook_event: str): # features incompatible with OpenRouter (``tool_reference`` content # blocks, ``context-management-2025-06-27`` beta). We neutralise these # at runtime by injecting ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` -# into the CLI subprocess env (see ``service.py``). +# into the CLI subprocess env (see ``build_sdk_env()`` in ``env.py``). # # This test is the cheapest possible regression guard: it pins the # bundled CLI to a known-good version. If anyone bumps From 099d5cf1b2841bd5c60ed52d2ac5fbc6a7a3e44c Mon Sep 17 00:00:00 2001 From: majdyz Date: Sun, 12 Apr 2026 23:18:56 +0000 Subject: [PATCH 27/34] test(copilot): assert CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS in subscription and direct-Anthropic modes Add the assertion to TestBuildSdkEnvSubscription.test_returns_blanked_keys and TestBuildSdkEnvDirectAnthropic.test_no_anthropic_key_overrides_when_openrouter_inactive. --- autogpt_platform/backend/backend/copilot/sdk/env_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/env_test.py b/autogpt_platform/backend/backend/copilot/sdk/env_test.py index cfc1fd3e3f..23badc30b2 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/env_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/env_test.py @@ -44,6 +44,7 @@ class TestBuildSdkEnvSubscription: assert result["ANTHROPIC_API_KEY"] == "" assert result["ANTHROPIC_AUTH_TOKEN"] == "" assert result["ANTHROPIC_BASE_URL"] == "" + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" mock_validate.assert_called_once() @patch( @@ -78,6 +79,7 @@ class TestBuildSdkEnvDirectAnthropic: assert "ANTHROPIC_API_KEY" not in result assert "ANTHROPIC_AUTH_TOKEN" not in result assert "ANTHROPIC_BASE_URL" not in result + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" def test_no_anthropic_key_overrides_when_openrouter_flag_true_but_no_key(self): """OpenRouter flag is True but no api_key => openrouter_active is False.""" From b044862dbabb245559661117bc63ba3dd88d8bd8 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 00:12:16 +0000 Subject: [PATCH 28/34] perf(copilot): add thinking token cap and lower default budget/turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cost investigation showed 54% of spend is thinking tokens (~57K/turn avg at $75/M for Opus). Add max_thinking_tokens config (default 8192) to cap extended thinking output per LLM call. Also lower defaults: - max_budget_usd: $100 → $5 per turn - max_turns: 1000 → 50 tool-use loops These are configurable via env vars (CHAT_CLAUDE_AGENT_MAX_THINKING_TOKENS, CHAT_CLAUDE_AGENT_MAX_BUDGET_USD, CHAT_CLAUDE_AGENT_MAX_TURNS). --- autogpt_platform/backend/backend/copilot/config.py | 13 +++++++++++-- .../backend/backend/copilot/sdk/service.py | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index bd1acbce83..b580bda08b 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -152,19 +152,28 @@ class ChatConfig(BaseSettings): "overloaded). The SDK automatically retries with this cheaper model.", ) claude_agent_max_turns: int = Field( - default=1000, + default=50, ge=1, le=10000, description="Maximum number of agentic turns (tool-use loops) per query. " "Prevents runaway tool loops from burning budget.", ) claude_agent_max_budget_usd: float = Field( - default=100.0, + default=5.0, ge=0.01, le=1000.0, description="Maximum spend in USD per SDK query. The CLI aborts the " "request if this budget is exceeded.", ) + claude_agent_max_thinking_tokens: int = Field( + default=8192, + ge=1024, + le=128000, + description="Maximum thinking/reasoning tokens per LLM call. " + "Extended thinking on Opus can generate 50k+ tokens at $75/M — " + "capping this is the single biggest cost lever. " + "8192 is sufficient for most tasks; increase for complex reasoning.", + ) claude_agent_max_transient_retries: int = Field( default=3, ge=0, diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py index 35b87cd40c..5ee6bba8ca 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/service.py +++ b/autogpt_platform/backend/backend/copilot/sdk/service.py @@ -2238,6 +2238,10 @@ async def stream_chat_completion_sdk( "max_turns": config.claude_agent_max_turns, # max_budget_usd: per-query spend ceiling enforced by the CLI. "max_budget_usd": config.claude_agent_max_budget_usd, + # max_thinking_tokens: cap extended thinking output per LLM call. + # Thinking tokens are billed at output rate ($75/M for Opus) and + # account for ~54% of total cost. 8192 is the default. + "max_thinking_tokens": config.claude_agent_max_thinking_tokens, } if sdk_model: sdk_options_kwargs["model"] = sdk_model From 497cc15a8bf8bd0ce922db487c22c0226bc6ea2e Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 00:22:50 +0000 Subject: [PATCH 29/34] fix(backend): update guardrail tests for new defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update test assertions to match new defaults: - max_turns: 1000 → 50 - max_budget_usd: 100.0 → 5.0 - Add test for max_thinking_tokens default (8192) --- .../backend/backend/copilot/sdk/p0_guardrails_test.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/sdk/p0_guardrails_test.py b/autogpt_platform/backend/backend/copilot/sdk/p0_guardrails_test.py index 613ccb2a09..c3ae67f67c 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/p0_guardrails_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/p0_guardrails_test.py @@ -203,11 +203,15 @@ class TestConfigDefaults: def test_max_turns_default(self): cfg = _make_config() - assert cfg.claude_agent_max_turns == 1000 + assert cfg.claude_agent_max_turns == 50 def test_max_budget_usd_default(self): cfg = _make_config() - assert cfg.claude_agent_max_budget_usd == 100.0 + assert cfg.claude_agent_max_budget_usd == 5.0 + + def test_max_thinking_tokens_default(self): + cfg = _make_config() + assert cfg.claude_agent_max_thinking_tokens == 8192 def test_max_transient_retries_default(self): cfg = _make_config() From e0d5047974a8d4b56c04ad84742eb5c5b8d600d9 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 03:53:41 +0000 Subject: [PATCH 30/34] test(copilot): plug two test coverage gaps found in round-5 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - env_test: add missing CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS assertion to test_no_anthropic_key_overrides_when_openrouter_flag_true_but_no_key (the other three build_sdk_env test cases already assert it; this case was the only one that didn't, leaving the env-var injection unverified for the openrouter_active=False / no-key path) - sdk_compat_test: add test_sdk_exposes_max_thinking_tokens_option parallel to the existing test_sdk_exposes_cli_path_option — guards against a future SDK rename/removal of max_thinking_tokens silently disabling the Opus thinking-token cost cap --- .../backend/backend/copilot/sdk/env_test.py | 1 + .../backend/copilot/sdk/sdk_compat_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/autogpt_platform/backend/backend/copilot/sdk/env_test.py b/autogpt_platform/backend/backend/copilot/sdk/env_test.py index 23badc30b2..3e748e5be2 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/env_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/env_test.py @@ -95,6 +95,7 @@ class TestBuildSdkEnvDirectAnthropic: assert "ANTHROPIC_API_KEY" not in result assert "ANTHROPIC_AUTH_TOKEN" not in result assert "ANTHROPIC_BASE_URL" not in result + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" # --------------------------------------------------------------------------- diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index b5ed5004b6..fcc03939bd 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -266,3 +266,21 @@ def test_sdk_exposes_cli_path_option(): "Either find an alternative override mechanism or pin the SDK to a " "version that still exposes it." ) + + +def test_sdk_exposes_max_thinking_tokens_option(): + """Sanity-check that the SDK still exposes the `max_thinking_tokens` option + we use to cap extended thinking cost. If upstream removes or renames it + the cap will be silently ignored and Opus thinking tokens will be unbounded.""" + import inspect + + from claude_agent_sdk import ClaudeAgentOptions + + sig = inspect.signature(ClaudeAgentOptions) + assert "max_thinking_tokens" in sig.parameters, ( + "ClaudeAgentOptions no longer accepts `max_thinking_tokens` — our " + "claude_agent_max_thinking_tokens cost cap would be silently ignored, " + "allowing Opus extended thinking to generate unbounded tokens at $75/M. " + "Find the correct parameter name in the new SDK version and update " + "ChatConfig.claude_agent_max_thinking_tokens and service.py accordingly." + ) From 5dbbdf9b2724f6cfdad07c7e04507b648cbc7cad Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 04:23:54 +0000 Subject: [PATCH 31/34] fix(copilot): address round-6 review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant inner `ChatConfig` import in `_prewarm_cli` — it was already imported at module scope on line 16 (style guide: inner imports only for heavy optional deps) - Correct stale comment in `sdk_compat_test.py`: 2.1.63/2.1.70 pre-date the context-management regression and are OpenRouter-safe without any env var; only 2.1.97+ requires CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 - Update `_assert_no_forbidden_patterns` error message in `cli_openrouter_compat_test.py`: remove the stale "above 0.1.45" ceiling (we've already upgraded to 0.1.58) and point at the correct remediation steps (add to _KNOWN_GOOD_BUNDLED_CLI_VERSIONS after bisect verification) - Plug test coverage gap in `env_test.py`: add `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS == "1"` assertions to three OpenRouter test methods that were missing it (test_strips_trailing_v1, test_strips_trailing_v1_and_slash, test_no_v1_suffix_left_alone) — guards against the env var being accidentally dropped from a code path that the main test didn't exercise --- .../backend/backend/copilot/executor/processor.py | 2 -- .../copilot/sdk/cli_openrouter_compat_test.py | 15 ++++++++------- .../backend/backend/copilot/sdk/env_test.py | 3 +++ .../backend/copilot/sdk/sdk_compat_test.py | 12 +++++++----- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/executor/processor.py b/autogpt_platform/backend/backend/copilot/executor/processor.py index 2f9e563784..96bcadcaab 100644 --- a/autogpt_platform/backend/backend/copilot/executor/processor.py +++ b/autogpt_platform/backend/backend/copilot/executor/processor.py @@ -183,8 +183,6 @@ class CoPilotProcessor: back to the bundled binary when no override is set. """ try: - from backend.copilot.config import ChatConfig - cfg = ChatConfig() cli_path: str | None = cfg.claude_agent_cli_path if not cli_path: diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 64bdf5f656..386631f8ec 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -443,14 +443,15 @@ def _assert_no_forbidden_patterns( f"Bundled Claude Code CLI sent OpenRouter-incompatible features in " f"{len(all_findings)} request(s):\n - " + "\n - ".join(all_findings) - + "\n\nThis is the regression that prevents us from upgrading " - "`claude-agent-sdk` above 0.1.45. See " - "https://github.com/Significant-Gravitas/AutoGPT/pull/12294 and " + + "\n\nThe bundled CLI is sending OpenRouter-incompatible features. " + "See https://github.com/Significant-Gravitas/AutoGPT/pull/12294 and " "https://github.com/anthropics/claude-agent-sdk-python/issues/789. " - "If you intended to upgrade, ensure " - "`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` is set in the SDK env " - "or use a known-good CLI binary via `claude_agent_cli_path` (env: " - "`CLAUDE_AGENT_CLI_PATH` or `CHAT_CLAUDE_AGENT_CLI_PATH`)." + "If you bumped `claude-agent-sdk`, verify the new bundled CLI works " + "with `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` set (injected by " + "``build_sdk_env()`` in ``env.py``), then add the CLI version to " + "`_KNOWN_GOOD_BUNDLED_CLI_VERSIONS` in `sdk_compat_test.py`. " + "Alternatively, pin a known-good binary via `claude_agent_cli_path` " + "(env: `CLAUDE_AGENT_CLI_PATH` or `CHAT_CLAUDE_AGENT_CLI_PATH`)." ) diff --git a/autogpt_platform/backend/backend/copilot/sdk/env_test.py b/autogpt_platform/backend/backend/copilot/sdk/env_test.py index 3e748e5be2..4418ff4ce4 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/env_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/env_test.py @@ -138,6 +138,7 @@ class TestBuildSdkEnvOpenRouter: result = build_sdk_env() assert result["ANTHROPIC_BASE_URL"] == "https://openrouter.ai/api" + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" def test_strips_trailing_v1_and_slash(self): """Trailing slash before /v1 strip is handled.""" @@ -149,6 +150,7 @@ class TestBuildSdkEnvOpenRouter: # rstrip("/") first, then remove /v1 assert result["ANTHROPIC_BASE_URL"] == "https://openrouter.ai/api" + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" def test_no_v1_suffix_left_alone(self): """A base URL without /v1 is used as-is.""" @@ -159,6 +161,7 @@ class TestBuildSdkEnvOpenRouter: result = build_sdk_env() assert result["ANTHROPIC_BASE_URL"] == "https://custom-proxy.example.com" + assert result.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS") == "1" def test_session_id_header(self): cfg = self._openrouter_config() diff --git a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py index fcc03939bd..c705d26c22 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/sdk_compat_test.py @@ -215,17 +215,19 @@ def test_sdk_exports_hook_event_type(hook_event: str): # message that points the next person at the OpenRouter compat issue # instead of letting them silently re-break production. -# CLI versions verified to work against OpenRouter when the -# ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` env var is set -- -# bisected via the reproduction test in ``cli_openrouter_compat_test.py``. +# CLI versions bisect-verified as OpenRouter-safe. 2.1.63 and 2.1.70 pre-date +# the context-management beta regression and work without any env var. 2.1.97+ +# requires ``CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`` (injected by +# ``build_sdk_env()`` in ``env.py``) to strip the beta header. _KNOWN_GOOD_BUNDLED_CLI_VERSIONS: frozenset[str] = frozenset( { "2.1.63", # claude-agent-sdk 0.1.45 -- original pin from PR #12294. "2.1.70", # claude-agent-sdk 0.1.47 -- first version with the # tool_reference proxy detection fix; bisect-verified # OpenRouter-safe in #12742. - "2.1.97", # claude-agent-sdk 0.1.58 -- works with the - # CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 env var. + "2.1.97", # claude-agent-sdk 0.1.58 -- OpenRouter-safe only with + # CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (injected by + # build_sdk_env() in env.py). } ) From 359b7f1b81b7e4c1f3c8a55ae7b202d3b7db0aa4 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 05:13:56 +0000 Subject: [PATCH 32/34] fix(copilot): address PR reviewer feedback on CLI path validation and defaults - Reject non-existent and non-file CLI paths at config validation time instead of letting them fail with opaque OS errors at runtime - Add negative test coverage for CLI path validator (non-existent, non-executable, directory paths) - Document breaking default changes (max_turns 1000->50, max_budget $100->$5) in field descriptions with env var override instructions - Narrow broad `except Exception` to `except (ImportError, AttributeError)` in cli_openrouter_compat_test.py --- .../backend/backend/copilot/config.py | 29 +++++++++++++----- .../backend/backend/copilot/config_test.py | 30 +++++++++++++++++++ .../copilot/sdk/cli_openrouter_compat_test.py | 2 +- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index b580bda08b..856b0effd7 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -156,14 +156,18 @@ class ChatConfig(BaseSettings): ge=1, le=10000, description="Maximum number of agentic turns (tool-use loops) per query. " - "Prevents runaway tool loops from burning budget.", + "Prevents runaway tool loops from burning budget. " + "Changed from 1000 to 50 in SDK 0.1.58 upgrade — override via " + "CHAT_CLAUDE_AGENT_MAX_TURNS env var if your workflows need more.", ) claude_agent_max_budget_usd: float = Field( default=5.0, ge=0.01, le=1000.0, description="Maximum spend in USD per SDK query. The CLI aborts the " - "request if this budget is exceeded.", + "request if this budget is exceeded. " + "Changed from $100 to $5 in SDK 0.1.58 upgrade — override via " + "CHAT_CLAUDE_AGENT_MAX_BUDGET_USD env var if needed.", ) claude_agent_max_thinking_tokens: int = Field( default=8192, @@ -335,11 +339,22 @@ class ChatConfig(BaseSettings): v = os.getenv("CHAT_CLAUDE_AGENT_CLI_PATH") if not v: v = os.getenv("CLAUDE_AGENT_CLI_PATH") - if v and os.path.exists(v) and not os.access(v, os.X_OK): - raise ValueError( - f"claude_agent_cli_path '{v}' exists but is not executable. " - "Check file permissions." - ) + if v: + if not os.path.exists(v): + raise ValueError( + f"claude_agent_cli_path '{v}' does not exist. " + "Check the path or unset CLAUDE_AGENT_CLI_PATH to use " + "the bundled CLI." + ) + if not os.path.isfile(v): + raise ValueError( + f"claude_agent_cli_path '{v}' is not a regular file." + ) + if not os.access(v, os.X_OK): + raise ValueError( + f"claude_agent_cli_path '{v}' exists but is not executable. " + "Check file permissions." + ) return v # Prompt paths for different contexts diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index 413a89277a..ebd0e4333b 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -122,3 +122,33 @@ class TestClaudeAgentCliPathEnvFallback: def test_no_env_var_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch) -> None: cfg = ChatConfig() assert cfg.claude_agent_cli_path is None + + def test_nonexistent_path_raises_validation_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Non-existent CLI path must be rejected at config time, not at + runtime when subprocess.run fails with an opaque OS error.""" + monkeypatch.setenv( + "CLAUDE_AGENT_CLI_PATH", "/opt/nonexistent/claude-cli-binary" + ) + with pytest.raises(Exception, match="does not exist"): + ChatConfig() + + def test_non_executable_path_raises_validation_error( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + """Path that exists but is not executable must be rejected.""" + non_exec = tmp_path / "claude-not-executable" + non_exec.write_text("#!/bin/sh\n") + non_exec.chmod(0o644) # readable but not executable + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(non_exec)) + with pytest.raises(Exception, match="not executable"): + ChatConfig() + + def test_directory_path_raises_validation_error( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + """Path pointing to a directory must be rejected.""" + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(tmp_path)) + with pytest.raises(Exception, match="not a regular file"): + ChatConfig() diff --git a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py index 386631f8ec..1ac2f5fbd5 100644 --- a/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py +++ b/autogpt_platform/backend/backend/copilot/sdk/cli_openrouter_compat_test.py @@ -295,7 +295,7 @@ def _resolve_cli_path() -> Path | None: bundled = cast(str, SubprocessCLITransport._find_bundled_cli(None)) return Path(bundled) if bundled else None - except Exception as e: # pragma: no cover - import-time guard + except (ImportError, AttributeError) as e: # pragma: no cover - import-time guard logger.warning("Could not locate bundled Claude CLI: %s", e) return None From a8cfe27f6b6292e1f9060604b3b25dea96cf13ca Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 05:28:08 +0000 Subject: [PATCH 33/34] fix: use real temp files in CLI path env var tests The path validator rejects non-existent paths, so tests must create real executable temp files via tmp_path instead of hardcoded paths. --- .../backend/backend/copilot/config_test.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config_test.py b/autogpt_platform/backend/backend/copilot/config_test.py index ebd0e4333b..fe8e67b7ff 100644 --- a/autogpt_platform/backend/backend/copilot/config_test.py +++ b/autogpt_platform/backend/backend/copilot/config_test.py @@ -98,26 +98,38 @@ class TestClaudeAgentCliPathEnvFallback: """ def test_prefixed_env_var_is_picked_up( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, tmp_path ) -> None: - monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", "/opt/claude-prefixed") + fake_cli = tmp_path / "fake-claude" + fake_cli.write_text("#!/bin/sh\n") + fake_cli.chmod(0o755) + monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", str(fake_cli)) cfg = ChatConfig() - assert cfg.claude_agent_cli_path == "/opt/claude-prefixed" + assert cfg.claude_agent_cli_path == str(fake_cli) def test_unprefixed_env_var_is_picked_up( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, tmp_path ) -> None: - monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", "/opt/claude-unprefixed") + fake_cli = tmp_path / "fake-claude" + fake_cli.write_text("#!/bin/sh\n") + fake_cli.chmod(0o755) + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(fake_cli)) cfg = ChatConfig() - assert cfg.claude_agent_cli_path == "/opt/claude-unprefixed" + assert cfg.claude_agent_cli_path == str(fake_cli) def test_prefixed_wins_over_unprefixed( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, tmp_path ) -> None: - monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", "/opt/claude-prefixed") - monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", "/opt/claude-unprefixed") + prefixed_cli = tmp_path / "fake-claude-prefixed" + prefixed_cli.write_text("#!/bin/sh\n") + prefixed_cli.chmod(0o755) + unprefixed_cli = tmp_path / "fake-claude-unprefixed" + unprefixed_cli.write_text("#!/bin/sh\n") + unprefixed_cli.chmod(0o755) + monkeypatch.setenv("CHAT_CLAUDE_AGENT_CLI_PATH", str(prefixed_cli)) + monkeypatch.setenv("CLAUDE_AGENT_CLI_PATH", str(unprefixed_cli)) cfg = ChatConfig() - assert cfg.claude_agent_cli_path == "/opt/claude-prefixed" + assert cfg.claude_agent_cli_path == str(prefixed_cli) def test_no_env_var_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch) -> None: cfg = ChatConfig() From 967f0c97c41375a62477fa5aab5bee47e7031eb9 Mon Sep 17 00:00:00 2001 From: majdyz Date: Mon, 13 Apr 2026 06:29:25 +0000 Subject: [PATCH 34/34] fix(copilot): fix black formatting for single-line ValueError raise --- autogpt_platform/backend/backend/copilot/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/config.py b/autogpt_platform/backend/backend/copilot/config.py index 856b0effd7..8e7ddd86c3 100644 --- a/autogpt_platform/backend/backend/copilot/config.py +++ b/autogpt_platform/backend/backend/copilot/config.py @@ -347,9 +347,7 @@ class ChatConfig(BaseSettings): "the bundled CLI." ) if not os.path.isfile(v): - raise ValueError( - f"claude_agent_cli_path '{v}' is not a regular file." - ) + raise ValueError(f"claude_agent_cli_path '{v}' is not a regular file.") if not os.access(v, os.X_OK): raise ValueError( f"claude_agent_cli_path '{v}' exists but is not executable. "