Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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")

Expand Down Expand Up @@ -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:
Expand All @@ -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()},
)

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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")

Expand Down Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions python/packages/anthropic/tests/test_anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
moonbox3 marked this conversation as resolved.


@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:
Expand Down
2 changes: 1 addition & 1 deletion python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,8 @@
"ContinuationToken",
"ConversationSplit",
"ConversationSplitter",
"Default",
"DeduplicatingSkillsSource",
"Default",
"DelegatingSkillsSource",
"Edge",
"EdgeCondition",
Expand Down
16 changes: 4 additions & 12 deletions python/packages/core/agent_framework/_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
)


Expand Down Expand Up @@ -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

Expand Down
24 changes: 15 additions & 9 deletions python/packages/core/tests/core/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand All @@ -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')}"

Expand All @@ -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')}"

Expand All @@ -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"

Expand All @@ -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")

Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)

Expand Down
Loading