diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 1d1b978dd2..98c181f152 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -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, diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 945e5356a4..0cfec3423c 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -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: diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index eb439c3543..4592f8c716 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -352,8 +352,8 @@ __all__ = [ "ContinuationToken", "ConversationSplit", "ConversationSplitter", - "Default", "DeduplicatingSkillsSource", + "Default", "DelegatingSkillsSource", "Edge", "EdgeCondition", diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 082c6f1b69..06612b4df0 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -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 diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 36906d55b8..8e8c6a8aed 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -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)