diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 1d1b978dd21..98c181f1526 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 @@ def __init__( *, 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 @@ def __init__( 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 @@ def __init__( 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 MyOptions(AnthropicChatOptions, total=False): 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 MyOptions(AnthropicChatOptions, total=False): 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 @@ def __init__( *, 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 @@ def __init__( 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 @@ def __init__( 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 MyOptions(AnthropicChatOptions, total=False): 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 945e5356a4f..0cfec3423c1 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 eb439c35435..4592f8c7169 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -352,8 +352,8 @@ "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 082c6f1b699..06612b4df0a 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -446,14 +446,10 @@ async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: """ 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 @@ def _get_validated_resource_path(skill_dir: str, resource_name: str) -> str: 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 36906d55b86..8e8c6a8aedf 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -1190,7 +1190,9 @@ async def get_user_data(**kwargs: Any) -> Any: 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 @@ async def test_read_sync_function(self) -> None: 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 @@ async def get_data() -> str: 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 @@ def get_config(**kwargs: Any) -> str: 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 @@ async def get_config(**kwargs: Any) -> str: 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 @@ def simple() -> str: 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 @@ async def async_func(x: int = 0) -> str: 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 @@ def process(**kwargs: Any) -> str: 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 @@ async def test_require_script_approval_does_not_affect_other_tools(self) -> None 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 @@ async def test_code_skill_no_scripts_element(self) -> None: 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 @@ async def test_file_source_with_script_runner(self, tmp_path: Path) -> None: ) (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 @@ async def source_runner(skill: Any, script: Any, args: Any = None) -> str: 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)