1515
1616import asyncio
1717import json
18+ import types
1819from pathlib import Path
1920from unittest .mock import MagicMock , patch
2021
@@ -50,6 +51,14 @@ def _config(**kwargs) -> ClaudeCodeAgentConfig:
5051def _make_agent (** kwargs ) -> ClaudeCodeAgent :
5152 with patch ("responses_api_agents.claude_code_agent.app.ClaudeCodeAgent.model_post_init" ):
5253 agent = ClaudeCodeAgent (config = _config (** kwargs ), server_client = MagicMock (spec = ServerClient ))
54+ # Patching model_post_init also skips Pydantic's private-attr initialization,
55+ # leaving __pydantic_private__ as None; restore declared private slots (e.g.
56+ # _proxy_handle) to their defaults so agent methods can read them.
57+ object .__setattr__ (
58+ agent ,
59+ "__pydantic_private__" ,
60+ {name : pa .get_default () for name , pa in type (agent ).__private_attributes__ .items ()},
61+ )
5362 agent .sem = asyncio .Semaphore (agent .config .concurrency )
5463 return agent
5564
@@ -58,6 +67,35 @@ def _event(type_: str, **kwargs) -> str:
5867 return json .dumps ({"type" : type_ , ** kwargs })
5968
6069
70+ def _run_claude_code_capturing (agent : ClaudeCodeAgent , tmp_path : Path ) -> tuple [str , str , dict ]:
71+ """Run ``_run_claude_code`` with the subprocess stubbed out, capturing the
72+ child process ``env`` and ``argv`` so env-threading / model-name handling
73+ can be asserted. Mirrors the ``TestRunClaudeCode`` fixtures."""
74+ captured : dict = {}
75+
76+ class FakeProc :
77+ returncode = 0
78+
79+ async def communicate (self ):
80+ return b'{"type":"result","usage":{"input_tokens":1,"output_tokens":1}}\n ' , b""
81+
82+ async def fake_exec (* cmd , ** kwargs ):
83+ captured ["cmd" ] = list (cmd )
84+ captured ["env" ] = kwargs ["env" ]
85+ return FakeProc ()
86+
87+ # Clear the ambient environment so absence assertions (e.g. no
88+ # ANTHROPIC_BASE_URL in plain mode) are deterministic regardless of the
89+ # developer's/CI shell. The subprocess is mocked, so env is never spawned.
90+ with (
91+ patch .dict ("responses_api_agents.claude_code_agent.app.os.environ" , {}, clear = True ),
92+ patch ("responses_api_agents.claude_code_agent.app.Path.home" , return_value = tmp_path ),
93+ patch ("responses_api_agents.claude_code_agent.app.asyncio.create_subprocess_exec" , fake_exec ),
94+ ):
95+ stdout , model = asyncio .run (agent ._run_claude_code ("hello" ))
96+ return stdout , model , captured
97+
98+
6199class TestSanity :
62100 def test_config_defaults (self ) -> None :
63101 cfg = _config ()
@@ -234,6 +272,59 @@ async def fake_wait_for(coro, timeout):
234272 assert killed ["called" ] is True
235273 assert model == "claude-sonnet-4-6"
236274
275+ def test_proxy_mode_threads_base_url_and_preserves_full_model (self , tmp_path : Path ) -> None :
276+ # A running adapter proxy in front of the model server: the SDK is
277+ # pointed at the proxy URL and the full (provider-prefixed) model name
278+ # is preserved for the custom upstream.
279+ agent = _make_agent (
280+ model = "anthropic/claude-sonnet-4-6" ,
281+ anthropic_api_key = "sk-test" , # pragma: allowlist secret
282+ )
283+ agent ._proxy_handle = types .SimpleNamespace (url = "http://127.0.0.1:9999" )
284+
285+ _stdout , model , captured = _run_claude_code_capturing (agent , tmp_path )
286+ env = captured ["env" ]
287+
288+ assert env ["ANTHROPIC_BASE_URL" ] == "http://127.0.0.1:9999"
289+ assert env ["ANTHROPIC_AUTH_TOKEN" ] == "sk-test" # pragma: allowlist secret
290+ # Full model name (provider prefix kept) for a custom upstream.
291+ assert model == "anthropic/claude-sonnet-4-6"
292+ assert env ["ANTHROPIC_MODEL" ] == "anthropic/claude-sonnet-4-6"
293+ assert captured ["cmd" ][captured ["cmd" ].index ("--model" ) + 1 ] == "anthropic/claude-sonnet-4-6"
294+
295+ def test_explicit_base_url_preserves_full_model (self , tmp_path : Path ) -> None :
296+ # A direct non-Anthropic endpoint via anthropic_base_url (no proxy):
297+ # base url is threaded through and the full model name is preserved.
298+ agent = _make_agent (
299+ model = "anthropic/claude-sonnet-4-6" ,
300+ anthropic_base_url = "https://my-endpoint.example/v1" ,
301+ anthropic_api_key = "sk-direct" , # pragma: allowlist secret
302+ )
303+ assert agent ._proxy_handle is None
304+
305+ _stdout , model , captured = _run_claude_code_capturing (agent , tmp_path )
306+ env = captured ["env" ]
307+
308+ assert env ["ANTHROPIC_BASE_URL" ] == "https://my-endpoint.example/v1"
309+ assert env ["ANTHROPIC_AUTH_TOKEN" ] == "sk-direct" # pragma: allowlist secret
310+ assert model == "anthropic/claude-sonnet-4-6"
311+ assert env ["ANTHROPIC_MODEL" ] == "anthropic/claude-sonnet-4-6"
312+
313+ def test_plain_anthropic_mode_strips_provider_prefix (self , tmp_path : Path ) -> None :
314+ # Plain Anthropic API mode (no proxy, no base url): the provider prefix
315+ # is stripped and no base-url/auth-token env is injected.
316+ agent = _make_agent (model = "anthropic/claude-sonnet-4-6" )
317+ assert agent ._proxy_handle is None
318+
319+ _stdout , model , captured = _run_claude_code_capturing (agent , tmp_path )
320+ env = captured ["env" ]
321+
322+ assert "ANTHROPIC_BASE_URL" not in env
323+ assert "ANTHROPIC_AUTH_TOKEN" not in env
324+ assert model == "claude-sonnet-4-6"
325+ assert env ["ANTHROPIC_MODEL" ] == "claude-sonnet-4-6"
326+ assert captured ["cmd" ][captured ["cmd" ].index ("--model" ) + 1 ] == "claude-sonnet-4-6"
327+
237328
238329class TestExtractInstruction :
239330 def test_user_only (self ) -> None :
0 commit comments