diff --git a/codemem/observer.py b/codemem/observer.py index 92290005f..4eb2f63aa 100644 --- a/codemem/observer.py +++ b/codemem/observer.py @@ -6,7 +6,9 @@ import subprocess from dataclasses import dataclass from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from . import observer_anthropic as _observer_anthropic from . import observer_auth as _observer_auth from . import observer_codex as _observer_codex from . import observer_config as _observer_config @@ -18,6 +20,7 @@ DEFAULT_ANTHROPIC_MODEL = "claude-4.5-haiku" CODEX_API_ENDPOINT = _observer_codex.CODEX_API_ENDPOINT DEFAULT_CODEX_ENDPOINT = _observer_codex.DEFAULT_CODEX_ENDPOINT +ANTHROPIC_MESSAGES_ENDPOINT = _observer_anthropic.ANTHROPIC_MESSAGES_ENDPOINT logger = logging.getLogger(__name__) @@ -102,6 +105,11 @@ def _is_claude_sidecar_auth_error(message: str) -> bool: _redact_text = _observer_codex._redact_text _resolve_oauth_provider = _observer_auth._resolve_oauth_provider +_build_anthropic_headers = _observer_anthropic._build_anthropic_headers +_build_anthropic_payload = _observer_anthropic._build_anthropic_payload +_parse_anthropic_stream = _observer_anthropic._parse_anthropic_stream +_resolve_anthropic_endpoint = _observer_anthropic._resolve_anthropic_endpoint + _build_codex_payload = _observer_codex._build_codex_payload _parse_codex_stream = _observer_codex._parse_codex_stream _resolve_codex_endpoint = _observer_codex._resolve_codex_endpoint @@ -121,6 +129,7 @@ def _is_claude_sidecar_auth_error(message: str) -> bool: _strip_json_comments = _observer_config._strip_json_comments _strip_trailing_commas = _observer_config._strip_trailing_commas +del _observer_anthropic del _observer_auth del _observer_codex del _observer_config @@ -217,6 +226,7 @@ def __init__(self) -> None: self.client: object | None = None self.codex_access: str | None = None self.codex_account_id: str | None = None + self.anthropic_oauth_access: str | None = None if self.runtime == "api_http": self._init_provider_client(force_refresh=False) @@ -225,6 +235,7 @@ def _init_provider_client(self, *, force_refresh: bool) -> None: self.client = None self.codex_access = None self.codex_account_id = None + self.anthropic_oauth_access = None oauth_cache = _load_opencode_oauth_cache() oauth_access = None @@ -282,13 +293,17 @@ def _init_provider_client(self, *, force_refresh: bool) -> None: if not self.auth.token: logger.warning("observer auth: missing anthropic api key") return - try: - import anthropic # type: ignore + if self.auth.source == "oauth" and oauth_access: + self.anthropic_oauth_access = oauth_access + logger.info("observer auth: using anthropic oauth consumer path") + else: + try: + import anthropic # type: ignore - self.client = anthropic.Anthropic(api_key=self.auth.token) - except Exception as exc: # pragma: no cover - logger.exception("observer auth: anthropic client init failed", exc_info=exc) - self.client = None + self.client = anthropic.Anthropic(api_key=self.auth.token) + except Exception as exc: # pragma: no cover + logger.exception("observer auth: anthropic client init failed", exc_info=exc) + self.client = None else: self.auth = self.auth_adapter.resolve( explicit_token=self.api_key, @@ -319,7 +334,9 @@ def _init_provider_client(self, *, force_refresh: bool) -> None: def _refresh_provider_client(self) -> bool: self.auth_adapter.invalidate_cache() self._init_provider_client(force_refresh=True) - return self.client is not None or bool(self.codex_access) + return ( + self.client is not None or bool(self.codex_access) or bool(self.anthropic_oauth_access) + ) def observe(self, context: ObserverContext) -> ObserverResponse: prompt = build_observer_prompt(context) @@ -428,10 +445,14 @@ def _call_claude_sidecar(self, prompt: str) -> str | None: return output def _call_once(self, prompt: str) -> str | None: + if self.anthropic_oauth_access: + return self._call_anthropic_consumer(prompt) if self.codex_access: return self._call_codex(prompt) if not self.client: self._refresh_provider_client() + if self.anthropic_oauth_access: + return self._call_anthropic_consumer(prompt) if self.codex_access: return self._call_codex(prompt) if not self.client: @@ -615,6 +636,110 @@ def _exc_chain(exc: BaseException, *, limit: int = 4) -> str: ) return None + def _call_anthropic_consumer(self, prompt: str) -> str | None: + if not self.anthropic_oauth_access: + logger.warning("observer auth: missing anthropic oauth access token") + return None + headers = _build_anthropic_headers(self.anthropic_oauth_access) + if self.observer_headers: + anthropic_auth = ObserverAuthMaterial( + token=self.anthropic_oauth_access, + auth_type="bearer", + source=self.auth.source, + ) + headers.update(_render_observer_headers(self.observer_headers, anthropic_auth)) + payload = _build_anthropic_payload(self.model, prompt, self.max_tokens) + endpoint = _resolve_anthropic_endpoint() + parsed_url = urlparse(endpoint) + params = parse_qs(parsed_url.query) + params["beta"] = ["true"] + url = urlunparse(parsed_url._replace(query=urlencode(params, doseq=True))) + + try: + import httpx + + with ( + httpx.Client(timeout=60) as client, + client.stream( + "POST", + url, + json=payload, + headers=headers, + ) as response, + ): + if response.status_code >= 400: + error_text = None + try: + response.read() + error_text = response.text + except Exception: + error_text = None + error_summary = _redact_text(error_text or "") + request_id = None + try: + request_id = response.headers.get("request-id") + except Exception: + request_id = None + message = "observer anthropic oauth call failed" + if error_summary: + message = f"{message}: {error_summary}" + if response.status_code in (401, 403): + raise ObserverAuthError(message) + logger.error( + message, + extra={ + "provider": self.provider, + "model": self.model, + "endpoint": endpoint, + "status": response.status_code, + "error": error_summary, + "request_id": request_id, + }, + ) + return None + response.raise_for_status() + return _parse_anthropic_stream(response) + except ObserverAuthError: + raise + except Exception as exc: # pragma: no cover + response = getattr(exc, "response", None) + status_code = getattr(response, "status_code", None) + error_text = None + if response is not None: + try: + response.read() + error_text = response.text + except Exception: + error_text = None + error_summary = _redact_text(error_text or "") + message = "observer anthropic oauth call failed" + if error_summary: + message = f"{message}: {error_summary}" + + request_url = None + try: + req = getattr(response, "request", None) or getattr(exc, "request", None) + request_url = str(getattr(req, "url", None) or "") or None + except Exception: + request_url = None + + if status_code in (401, 403) or _is_auth_error(exc): + raise ObserverAuthError(message) from exc + logger.exception( + message, + extra={ + "provider": self.provider, + "model": self.model, + "endpoint": endpoint, + "request_url": request_url, + "status": status_code, + "error": error_summary, + "exc_type": exc.__class__.__name__, + }, + exc_info=exc, + ) + return None + def _extract_opencode_text(self, output: str) -> str: if not output: return "" diff --git a/codemem/observer_anthropic.py b/codemem/observer_anthropic.py new file mode 100644 index 000000000..687ecd4c2 --- /dev/null +++ b/codemem/observer_anthropic.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +import os +from typing import Any + +from . import observer_auth as _observer_auth + +ANTHROPIC_MESSAGES_ENDPOINT = "https://api.anthropic.com/v1/messages" +ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20" +ANTHROPIC_OAUTH_USER_AGENT = "claude-cli/2.1.2 (external, cli)" + +_redact_text = _observer_auth._redact_text + + +def _resolve_anthropic_endpoint() -> str: + return os.getenv("CODEMEM_ANTHROPIC_ENDPOINT", ANTHROPIC_MESSAGES_ENDPOINT) + + +def _build_anthropic_headers(access_token: str) -> dict[str, str]: + return { + "authorization": f"Bearer {access_token}", + "anthropic-beta": ANTHROPIC_OAUTH_BETA, + "anthropic-version": "2023-06-01", + "user-agent": ANTHROPIC_OAUTH_USER_AGENT, + "content-type": "application/json", + } + + +def _build_anthropic_payload(model: str, prompt: str, max_tokens: int) -> dict[str, Any]: + return { + "model": model, + "max_tokens": max_tokens, + "stream": True, + "messages": [ + { + "role": "user", + "content": prompt, + } + ], + "system": "You are a memory observer.", + } + + +def _parse_anthropic_stream(response: Any) -> str | None: + """Parse Anthropic Messages API SSE stream, extracting text deltas.""" + text_parts: list[str] = [] + for line in response.iter_lines(): + if not line: + continue + decoded = line.decode("utf-8") if isinstance(line, (bytes, bytearray)) else str(line) + if not decoded.startswith("data:"): + continue + payload = decoded[len("data:") :].strip() + if not payload or payload == "[DONE]": + continue + try: + event = json.loads(payload) + except json.JSONDecodeError: + continue + # Anthropic streaming events: + # - content_block_delta with delta.type == "text_delta" and delta.text + event_type = event.get("type") + if event_type == "content_block_delta": + delta = event.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "text_delta": + text = delta.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + if text_parts: + return "".join(text_parts).strip() + return None diff --git a/tests/test_observer_anthropic.py b/tests/test_observer_anthropic.py new file mode 100644 index 000000000..1c72a283f --- /dev/null +++ b/tests/test_observer_anthropic.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +import pytest + +from codemem.config import OpencodeMemConfig +from codemem.observer import ObserverAuthError, ObserverClient +from codemem.observer_anthropic import ( + ANTHROPIC_MESSAGES_ENDPOINT, + ANTHROPIC_OAUTH_BETA, + ANTHROPIC_OAUTH_USER_AGENT, + _build_anthropic_headers, + _build_anthropic_payload, + _parse_anthropic_stream, + _resolve_anthropic_endpoint, +) + + +def test_build_anthropic_headers_sets_bearer_auth() -> None: + headers = _build_anthropic_headers("test-token") + assert headers["authorization"] == "Bearer test-token" + assert ANTHROPIC_OAUTH_BETA in headers["anthropic-beta"] + assert headers["user-agent"] == ANTHROPIC_OAUTH_USER_AGENT + assert headers["content-type"] == "application/json" + assert headers["anthropic-version"] == "2023-06-01" + assert "x-api-key" not in headers + + +def test_build_anthropic_payload_structure() -> None: + payload = _build_anthropic_payload("claude-4.5-haiku", "hello world", 1024) + assert payload["model"] == "claude-4.5-haiku" + assert payload["max_tokens"] == 1024 + assert payload["stream"] is True + assert payload["system"] == "You are a memory observer." + assert len(payload["messages"]) == 1 + assert payload["messages"][0]["role"] == "user" + assert payload["messages"][0]["content"] == "hello world" + + +def test_resolve_anthropic_endpoint_default() -> None: + with patch.dict("os.environ", {}, clear=False): + endpoint = _resolve_anthropic_endpoint() + assert endpoint == ANTHROPIC_MESSAGES_ENDPOINT + + +def test_resolve_anthropic_endpoint_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CODEMEM_ANTHROPIC_ENDPOINT", "https://custom.example/v1/messages") + assert _resolve_anthropic_endpoint() == "https://custom.example/v1/messages" + + +class _FakeStreamResponse: + """Simulate httpx streaming response with iter_lines().""" + + def __init__( + self, + lines: Sequence[Any], + *, + status_code: int = 200, + headers: dict[str, str] | None = None, + text: str = "", + ) -> None: + self._lines = lines + self.status_code = status_code + self.headers = headers or {} + self.text = text + + def iter_lines(self): + yield from self._lines + + def raise_for_status(self) -> None: + pass + + def read(self) -> None: + pass + + +def test_parse_anthropic_stream_extracts_text_deltas() -> None: + lines = [ + "event: message_start", + 'data: {"type": "message_start", "message": {"id": "msg_1"}}', + "", + "event: content_block_start", + 'data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}', + "", + "event: content_block_delta", + 'data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}', + "", + "event: content_block_delta", + 'data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " world"}}', + "", + "event: content_block_stop", + 'data: {"type": "content_block_stop", "index": 0}', + "", + "event: message_stop", + 'data: {"type": "message_stop"}', + ] + response = _FakeStreamResponse(lines) + result = _parse_anthropic_stream(response) + assert result == "Hello world" + + +def test_parse_anthropic_stream_handles_empty_response() -> None: + response = _FakeStreamResponse([]) + assert _parse_anthropic_stream(response) is None + + +def test_parse_anthropic_stream_handles_no_text_deltas() -> None: + lines = [ + 'data: {"type": "message_start", "message": {"id": "msg_1"}}', + 'data: {"type": "message_stop"}', + ] + response = _FakeStreamResponse(lines) + assert _parse_anthropic_stream(response) is None + + +def test_parse_anthropic_stream_handles_bytes() -> None: + lines = [ + b'data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}}', + ] + response = _FakeStreamResponse(lines) + result = _parse_anthropic_stream(response) + assert result == "Hi" + + +def test_parse_anthropic_stream_skips_malformed_json() -> None: + lines = [ + "data: not valid json", + 'data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Ok"}}', + ] + response = _FakeStreamResponse(lines) + result = _parse_anthropic_stream(response) + assert result == "Ok" + + +def test_parse_anthropic_stream_skips_done_marker() -> None: + lines = [ + 'data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Result"}}', + "data: [DONE]", + ] + response = _FakeStreamResponse(lines) + result = _parse_anthropic_stream(response) + assert result == "Result" + + +def test_parse_anthropic_stream_ignores_non_text_deltas() -> None: + """Ensure we only extract text_delta, not other delta types like input_json_delta.""" + lines = [ + 'data: {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{"}}', + 'data: {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "Actual text"}}', + ] + response = _FakeStreamResponse(lines) + result = _parse_anthropic_stream(response) + assert result == "Actual text" + + +# --------------------------------------------------------------------------- +# Integration tests: ObserverClient with Anthropic OAuth consumer path +# --------------------------------------------------------------------------- + + +def _anthropic_sse_lines(text: str) -> list[str]: + """Build minimal Anthropic SSE lines for a text response.""" + return [ + 'data: {"type": "message_start", "message": {"id": "msg_1"}}', + f'data: {{"type": "content_block_delta", "index": 0, "delta": {{"type": "text_delta", "text": "{text}"}}}}', + 'data: {"type": "message_stop"}', + ] + + +def _make_anthropic_oauth_client( + tmp_path: Path, + *, + access_token: str = "anthropic-access", + expires: int = 9999999999999, +) -> ObserverClient: + auth_path = tmp_path / "auth.json" + auth_path.write_text( + json.dumps( + { + "anthropic": { + "type": "oauth", + "access": access_token, + "refresh": "anthropic-refresh", + "expires": expires, + } + } + ) + ) + cfg = OpencodeMemConfig(observer_api_key=None, observer_provider="anthropic") + with ( + patch("codemem.observer.load_config", return_value=cfg), + patch("codemem.observer._get_opencode_auth_path", return_value=auth_path), + patch.dict("os.environ", {}, clear=True), + ): + return ObserverClient() + + +def test_anthropic_consumer_call_success(tmp_path: Path) -> None: + """Full round-trip: OAuth token → consumer endpoint → parsed response.""" + client = _make_anthropic_oauth_client(tmp_path) + assert client.anthropic_oauth_access == "anthropic-access" + + stream_lines = _anthropic_sse_lines("observer result") + fake_response = _FakeStreamResponse(stream_lines, status_code=200) + + class FakeHTTPXClient: + def __init__(self, **_kwargs: object) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *_args: object): + pass + + def stream(self, method: str, url: str, **kwargs: object): + self._captured_url = url + self._captured_headers = kwargs.get("headers", {}) + return _StreamContextManager(fake_response) + + class _StreamContextManager: + def __init__(self, resp: object) -> None: + self._resp = resp + + def __enter__(self): + return self._resp + + def __exit__(self, *_args: object): + pass + + httpx_module = SimpleNamespace(Client=FakeHTTPXClient) + with patch.dict(sys.modules, {"httpx": httpx_module}): + result = client._call("hello") + + assert result == "observer result" + + +def test_anthropic_consumer_call_auth_error(tmp_path: Path) -> None: + """401 from the API raises ObserverAuthError.""" + client = _make_anthropic_oauth_client(tmp_path) + + fake_response = _FakeStreamResponse([], status_code=401, text="Unauthorized") + + class FakeHTTPXClient: + def __init__(self, **_kwargs: object) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *_args: object): + pass + + def stream(self, method: str, url: str, **kwargs: object): + return _StreamContextManager(fake_response) + + class _StreamContextManager: + def __init__(self, resp: object) -> None: + self._resp = resp + + def __enter__(self): + return self._resp + + def __exit__(self, *_args: object): + pass + + httpx_module = SimpleNamespace(Client=FakeHTTPXClient) + with ( + patch.dict(sys.modules, {"httpx": httpx_module}), + pytest.raises(ObserverAuthError), + ): + client._call("hello") + + +def test_anthropic_consumer_sends_correct_headers(tmp_path: Path) -> None: + """Verify the consumer path sends Bearer auth and the oauth beta header.""" + client = _make_anthropic_oauth_client(tmp_path) + + captured_headers: dict[str, str] = {} + + stream_lines = _anthropic_sse_lines("ok") + fake_response = _FakeStreamResponse(stream_lines, status_code=200) + + class FakeHTTPXClient: + def __init__(self, **_kwargs: object) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *_args: object): + pass + + def stream(self, method: str, url: str, **kwargs: object): + hdrs = kwargs.get("headers") + if isinstance(hdrs, dict): + captured_headers.update(hdrs) + self._captured_url = url + return _StreamContextManager(fake_response) + + class _StreamContextManager: + def __init__(self, resp: object) -> None: + self._resp = resp + + def __enter__(self): + return self._resp + + def __exit__(self, *_args: object): + pass + + httpx_module = SimpleNamespace(Client=FakeHTTPXClient) + with patch.dict(sys.modules, {"httpx": httpx_module}): + client._call("hello") + + assert captured_headers["authorization"] == "Bearer anthropic-access" + assert ANTHROPIC_OAUTH_BETA in captured_headers["anthropic-beta"] + assert captured_headers["user-agent"] == ANTHROPIC_OAUTH_USER_AGENT + assert "x-api-key" not in captured_headers + + +def test_anthropic_consumer_url_has_beta_param(tmp_path: Path) -> None: + """Verify the request URL includes ?beta=true.""" + client = _make_anthropic_oauth_client(tmp_path) + + captured_urls: list[str] = [] + + stream_lines = _anthropic_sse_lines("ok") + fake_response = _FakeStreamResponse(stream_lines, status_code=200) + + class FakeHTTPXClient: + def __init__(self, **_kwargs: object) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *_args: object): + pass + + def stream(self, method: str, url: str, **kwargs: object): + captured_urls.append(url) + return _StreamContextManager(fake_response) + + class _StreamContextManager: + def __init__(self, resp: object) -> None: + self._resp = resp + + def __enter__(self): + return self._resp + + def __exit__(self, *_args: object): + pass + + httpx_module = SimpleNamespace(Client=FakeHTTPXClient) + with patch.dict(sys.modules, {"httpx": httpx_module}): + client._call("hello") + + assert len(captured_urls) == 1 + assert "beta=true" in captured_urls[0] + assert captured_urls[0].startswith(ANTHROPIC_MESSAGES_ENDPOINT) + + +def test_anthropic_consumer_url_preserves_existing_query_params( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """When CODEMEM_ANTHROPIC_ENDPOINT has query params, beta=true is appended correctly.""" + monkeypatch.setenv( + "CODEMEM_ANTHROPIC_ENDPOINT", + "https://proxy.example/v1/messages?api-version=2023-06-01", + ) + client = _make_anthropic_oauth_client(tmp_path) + + captured_urls: list[str] = [] + + stream_lines = _anthropic_sse_lines("ok") + fake_response = _FakeStreamResponse(stream_lines, status_code=200) + + class FakeHTTPXClient: + def __init__(self, **_kwargs: object) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *_args: object): + pass + + def stream(self, method: str, url: str, **kwargs: object): + captured_urls.append(url) + return _StreamContextManager(fake_response) + + class _StreamContextManager: + def __init__(self, resp: object) -> None: + self._resp = resp + + def __enter__(self): + return self._resp + + def __exit__(self, *_args: object): + pass + + httpx_module = SimpleNamespace(Client=FakeHTTPXClient) + with patch.dict(sys.modules, {"httpx": httpx_module}): + client._call("hello") + + assert len(captured_urls) == 1 + assert "api-version=2023-06-01" in captured_urls[0] + assert "beta=true" in captured_urls[0] + assert "?" in captured_urls[0] + # Should not have double question marks + assert "??" not in captured_urls[0] diff --git a/tests/test_observer_auth.py b/tests/test_observer_auth.py index 17e4fa1e7..d5c447cb8 100644 --- a/tests/test_observer_auth.py +++ b/tests/test_observer_auth.py @@ -106,7 +106,7 @@ def test_openai_client_uses_oauth_token_when_api_key_missing(tmp_path: Path) -> assert client.client.kwargs["api_key"] == "oa-access" -def test_anthropic_client_uses_oauth_token_when_api_key_missing(tmp_path: Path) -> None: +def test_anthropic_oauth_uses_consumer_path_when_api_key_missing(tmp_path: Path) -> None: auth_path = tmp_path / "auth.json" auth_path.write_text( json.dumps( @@ -120,8 +120,36 @@ def test_anthropic_client_uses_oauth_token_when_api_key_missing(tmp_path: Path) } ) ) - anthropic_module = SimpleNamespace(Anthropic=Mock()) cfg = OpencodeMemConfig(observer_api_key=None, observer_provider="anthropic") + with ( + patch("codemem.observer.load_config", return_value=cfg), + patch("codemem.observer._get_opencode_auth_path", return_value=auth_path), + patch.dict("os.environ", {}, clear=True), + ): + from codemem.observer import ObserverClient + + client = ObserverClient() + assert client.anthropic_oauth_access == "anthropic-access" + assert client.client is None + + +def test_anthropic_api_key_uses_sdk_client_not_consumer_path(tmp_path: Path) -> None: + """When an explicit API key is set, use the Anthropic SDK client, not the OAuth consumer path.""" + auth_path = tmp_path / "auth.json" + auth_path.write_text( + json.dumps( + { + "anthropic": { + "type": "oauth", + "access": "anthropic-access", + "refresh": "anthropic-refresh", + "expires": 9999999999999, + } + } + ) + ) + anthropic_module = SimpleNamespace(Anthropic=Mock()) + cfg = OpencodeMemConfig(observer_api_key="explicit-key", observer_provider="anthropic") with ( patch("codemem.observer.load_config", return_value=cfg), patch("codemem.observer._get_opencode_auth_path", return_value=auth_path), @@ -131,8 +159,9 @@ def test_anthropic_client_uses_oauth_token_when_api_key_missing(tmp_path: Path) from codemem.observer import ObserverClient client = ObserverClient() + assert client.anthropic_oauth_access is None assert client.client is not None - anthropic_module.Anthropic.assert_called_once_with(api_key="anthropic-access") + anthropic_module.Anthropic.assert_called_once_with(api_key="explicit-key") def test_oauth_skips_when_api_key_present(tmp_path: Path) -> None: