mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add base_url parameter to AnthropicClient and RawAnthropicClient (#5685)
* feat(anthropic): add base_url parameter to AnthropicClient and RawAnthropicClient Add base_url support to AnthropicSettings TypedDict, RawAnthropicClient, and AnthropicClient so users can point the client at Foundry or other Anthropic-compatible endpoints without having to construct AsyncAnthropic manually. - Add base_url field to AnthropicSettings (resolved from ANTHROPIC_BASE_URL env var) - Add base_url parameter to RawAnthropicClient.__init__ and pass it to AsyncAnthropic - Add base_url parameter to AnthropicClient.__init__ and forward to super - Add unit tests for base_url on both client classes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Add `base_url` parameter to `AnthropicClient` and `RawAnthropicClient` Fixes #5683 * test: add ANTHROPIC_BASE_URL env fallback tests for issue #5683 Add unit tests verifying that both AnthropicClient and RawAnthropicClient pick up base_url from the ANTHROPIC_BASE_URL environment variable via load_settings when base_url is not passed explicitly as a constructor arg. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(anthropic): explicit base_url kwarg beats ANTHROPIC_BASE_URL env var (#5683) Add regression tests asserting that when both ANTHROPIC_BASE_URL is set in the environment *and* an explicit base_url kwarg is passed to AnthropicClient / RawAnthropicClient, the explicit kwarg wins. This closes the priority-ordering contract (explicit arg > env var) that the existing tests left implicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
44381c051b
commit
8bb4692678
@@ -216,10 +216,12 @@ class AnthropicSettings(TypedDict, total=False):
|
||||
Keys:
|
||||
api_key: The Anthropic API key.
|
||||
chat_model: The Anthropic chat model.
|
||||
base_url: Optional base URL for the Anthropic API endpoint.
|
||||
"""
|
||||
|
||||
api_key: SecretString | None
|
||||
chat_model: str | None
|
||||
base_url: str | None
|
||||
|
||||
|
||||
class RawAnthropicClient(
|
||||
@@ -248,6 +250,7 @@ class RawAnthropicClient(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
anthropic_client: AnthropicAsyncClient | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
@@ -259,6 +262,8 @@ class RawAnthropicClient(
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
model: The model to use.
|
||||
base_url: Optional base URL for the Anthropic API endpoint. Useful for Foundry or
|
||||
other compatible deployments. Falls back to ``ANTHROPIC_BASE_URL`` env variable.
|
||||
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
|
||||
This can be used to further configure the client before passing it in.
|
||||
For instance if you need to set a different base_url for testing or private deployments.
|
||||
@@ -284,6 +289,13 @@ class RawAnthropicClient(
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or with a custom base URL (e.g. for Foundry-compatible endpoints)
|
||||
client = RawAnthropicClient(
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
base_url="https://custom-anthropic-endpoint.com",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = RawAnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
@@ -316,12 +328,14 @@ class RawAnthropicClient(
|
||||
env_prefix="ANTHROPIC_",
|
||||
api_key=api_key,
|
||||
chat_model=model,
|
||||
base_url=base_url,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
api_key_secret = anthropic_settings.get("api_key")
|
||||
model_setting = anthropic_settings.get("chat_model")
|
||||
base_url_setting = anthropic_settings.get("base_url")
|
||||
|
||||
if anthropic_client is None:
|
||||
if api_key_secret is None:
|
||||
@@ -332,6 +346,7 @@ class RawAnthropicClient(
|
||||
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key=api_key_secret.get_secret_value(),
|
||||
base_url=base_url_setting,
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
@@ -1409,6 +1424,7 @@ class AnthropicClient(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
anthropic_client: AnthropicAsyncClient | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
@@ -1422,6 +1438,8 @@ class AnthropicClient(
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
model: The model to use.
|
||||
base_url: Optional base URL for the Anthropic API endpoint. Useful for Foundry or
|
||||
other compatible deployments. Falls back to ``ANTHROPIC_BASE_URL`` env variable.
|
||||
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
|
||||
This can be used to further configure the client before passing it in.
|
||||
For instance if you need to set a different base_url for testing or private deployments.
|
||||
@@ -1448,6 +1466,13 @@ class AnthropicClient(
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or with a custom base URL (e.g. for Foundry-compatible endpoints)
|
||||
client = AnthropicClient(
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
base_url="https://custom-anthropic-endpoint.com",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = AnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
@@ -1477,6 +1502,7 @@ class AnthropicClient(
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
anthropic_client=anthropic_client,
|
||||
additional_beta_flags=additional_beta_flags,
|
||||
additional_properties=additional_properties,
|
||||
|
||||
@@ -149,6 +149,108 @@ def test_anthropic_client_init_auto_create_client(
|
||||
assert client.model == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"]
|
||||
|
||||
|
||||
def test_anthropic_client_init_with_base_url(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test AnthropicClient accepts a base_url and passes it to the underlying AsyncAnthropic client."""
|
||||
custom_url = "https://custom-anthropic-endpoint.com"
|
||||
client = AnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=custom_url,
|
||||
)
|
||||
|
||||
assert custom_url in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
def test_raw_anthropic_client_init_with_base_url(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test RawAnthropicClient accepts a base_url and passes it to the underlying AsyncAnthropic client."""
|
||||
custom_url = "https://custom-anthropic-endpoint.com"
|
||||
client = RawAnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=custom_url,
|
||||
)
|
||||
|
||||
assert custom_url in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_anthropic_client_init_base_url_from_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test AnthropicClient picks up base_url from ANTHROPIC_BASE_URL env variable when not passed explicitly."""
|
||||
client = AnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
)
|
||||
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_raw_anthropic_client_init_base_url_from_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test RawAnthropicClient picks up base_url from ANTHROPIC_BASE_URL env variable when not passed explicitly."""
|
||||
client = RawAnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
)
|
||||
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_anthropic_client_init_explicit_base_url_wins_over_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that an explicit base_url kwarg takes priority over ANTHROPIC_BASE_URL env variable."""
|
||||
explicit_url = "https://explicit-endpoint.example.com"
|
||||
client = AnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=explicit_url,
|
||||
)
|
||||
|
||||
assert explicit_url in str(client.anthropic_client.base_url)
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] not in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_raw_anthropic_client_init_explicit_base_url_wins_over_env(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that an explicit base_url kwarg takes priority over ANTHROPIC_BASE_URL env variable."""
|
||||
explicit_url = "https://explicit-endpoint.example.com"
|
||||
client = RawAnthropicClient(
|
||||
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
|
||||
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
|
||||
base_url=explicit_url,
|
||||
)
|
||||
|
||||
assert explicit_url in str(client.anthropic_client.base_url)
|
||||
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] not in str(client.anthropic_client.base_url)
|
||||
|
||||
|
||||
def test_anthropic_client_init_missing_api_key() -> None:
|
||||
"""Test AnthropicClient initialization when API key is missing."""
|
||||
with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load:
|
||||
|
||||
@@ -352,8 +352,8 @@ __all__ = [
|
||||
"ContinuationToken",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
"Default",
|
||||
"DeduplicatingSkillsSource",
|
||||
"Default",
|
||||
"DelegatingSkillsSource",
|
||||
"Edge",
|
||||
"EdgeCondition",
|
||||
|
||||
@@ -446,14 +446,10 @@ class FileSkillScript(SkillScript):
|
||||
"""
|
||||
if not isinstance(skill, FileSkill):
|
||||
raise TypeError(
|
||||
f"File-based script '{self.name}' requires a FileSkill "
|
||||
f"but received '{type(skill).__name__}'."
|
||||
f"File-based script '{self.name}' requires a FileSkill but received '{type(skill).__name__}'."
|
||||
)
|
||||
if self._runner is None:
|
||||
raise ValueError(
|
||||
f"Script '{self.name}' requires a runner. "
|
||||
"Provide a script_runner for file-based scripts."
|
||||
)
|
||||
raise ValueError(f"Script '{self.name}' requires a runner. Provide a script_runner for file-based scripts.")
|
||||
result = self._runner(skill, self, args)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
@@ -570,8 +566,7 @@ def _validate_skill_description(name: str, description: str) -> None:
|
||||
raise ValueError("Skill description cannot be empty.")
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
raise ValueError(
|
||||
f"Skill '{name}' has an invalid description: "
|
||||
f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
|
||||
f"Skill '{name}' has an invalid description: Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
|
||||
)
|
||||
|
||||
|
||||
@@ -1993,10 +1988,7 @@ class FileSkillsSource(SkillsSource):
|
||||
raise ValueError(f"Resource file '{resource_name}' not found in skill directory '{skill_dir}'.")
|
||||
|
||||
if FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path):
|
||||
raise ValueError(
|
||||
f"Resource file '{resource_name}' "
|
||||
"has a symlink in its path; symlinks are not allowed."
|
||||
)
|
||||
raise ValueError(f"Resource file '{resource_name}' has a symlink in its path; symlinks are not allowed.")
|
||||
|
||||
return resource_full_path
|
||||
|
||||
|
||||
@@ -1190,7 +1190,9 @@ class TestSkillsProviderCodeSkill:
|
||||
|
||||
provider = SkillsProvider([skill])
|
||||
await _init_provider(provider)
|
||||
result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc")
|
||||
result = await provider._read_skill_resource(
|
||||
_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc"
|
||||
)
|
||||
assert result == "data with token=abc"
|
||||
|
||||
async def test_read_callable_resource_without_kwargs_ignores_extra_args(self) -> None:
|
||||
@@ -2059,6 +2061,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_async_function(self) -> None:
|
||||
"""read() awaits an async function and returns its result."""
|
||||
|
||||
async def get_data() -> str:
|
||||
return "async result"
|
||||
|
||||
@@ -2068,6 +2071,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_function_with_kwargs(self) -> None:
|
||||
"""read() forwards kwargs to functions that accept them."""
|
||||
|
||||
def get_config(**kwargs: Any) -> str:
|
||||
return f"user={kwargs.get('user_id')}"
|
||||
|
||||
@@ -2077,6 +2081,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_async_function_with_kwargs(self) -> None:
|
||||
"""read() forwards kwargs to async functions that accept them."""
|
||||
|
||||
async def get_config(**kwargs: Any) -> str:
|
||||
return f"user={kwargs.get('user_id')}"
|
||||
|
||||
@@ -2086,6 +2091,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_function_without_kwargs_ignores_extra(self) -> None:
|
||||
"""read() does not pass kwargs to functions that don't accept them."""
|
||||
|
||||
def simple() -> str:
|
||||
return "fixed"
|
||||
|
||||
@@ -2095,6 +2101,7 @@ class TestSkillResourceRead:
|
||||
|
||||
async def test_read_function_raises_propagates(self) -> None:
|
||||
"""read() propagates exceptions from the function."""
|
||||
|
||||
def failing() -> str:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@@ -2747,6 +2754,7 @@ class TestSkillsProviderFactories:
|
||||
|
||||
async def test_code_script_returns_object(self) -> None:
|
||||
"""Code-defined scripts can return non-string objects."""
|
||||
|
||||
def returns_dict() -> dict:
|
||||
return {"status": "ok", "value": 42}
|
||||
|
||||
@@ -2855,8 +2863,8 @@ class TestSkillsProviderFactories:
|
||||
|
||||
provider = SkillsProvider([skill])
|
||||
await _init_provider(provider)
|
||||
result = await provider._run_skill_script(_raw_skills(provider),
|
||||
"my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
|
||||
result = await provider._run_skill_script(
|
||||
_raw_skills(provider), "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
|
||||
)
|
||||
assert "Error" in result
|
||||
|
||||
@@ -2946,6 +2954,7 @@ class TestSkillsProviderFactories:
|
||||
|
||||
async def test_code_script_exception_returns_error(self) -> None:
|
||||
"""A code script function that raises should return an error string."""
|
||||
|
||||
def failing_script() -> str:
|
||||
raise RuntimeError("Something went wrong")
|
||||
|
||||
@@ -3170,6 +3179,7 @@ class TestLoadSkillWithScripts:
|
||||
|
||||
async def test_code_skill_scripts_element_contains_parameters(self) -> None:
|
||||
"""Scripts XML includes parameters schema when the function has typed parameters."""
|
||||
|
||||
def analyze(query: str, limit: int = 10) -> str:
|
||||
return "result"
|
||||
|
||||
@@ -3755,9 +3765,7 @@ class TestSourceComposition:
|
||||
)
|
||||
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
|
||||
|
||||
source = DeduplicatingSkillsSource(
|
||||
FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner)
|
||||
)
|
||||
source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner))
|
||||
provider = SkillsProvider(source)
|
||||
await _init_provider(provider)
|
||||
assert "my-skill" in _ctx(provider)[0]
|
||||
@@ -3798,9 +3806,7 @@ class TestSourceComposition:
|
||||
call_log.append("source")
|
||||
return "source"
|
||||
|
||||
source = DeduplicatingSkillsSource(
|
||||
FileSkillsSource(str(tmp_path), script_runner=source_runner)
|
||||
)
|
||||
source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=source_runner))
|
||||
provider = SkillsProvider(source)
|
||||
await _init_provider(provider)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user