Skip to content
Open
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
20 changes: 11 additions & 9 deletions src/skillspector/providers/anthropic/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@

"""Anthropic provider — Claude models via api.anthropic.com.

Reads ``ANTHROPIC_API_KEY`` for credentials and constructs
``langchain_anthropic.ChatAnthropic`` directly. Defaults to Opus 4.6 for
analyzers and Sonnet 4.6 for ``meta_analyzer`` (cheaper for the
high-volume filter pass), mirroring the policy used by
``NvInferenceProvider``.
Reads ``ANTHROPIC_API_KEY`` for credentials and honors ``ANTHROPIC_BASE_URL``
as an explicit endpoint override (e.g. a local proxy); when unset, requests
go to api.anthropic.com. Constructs ``langchain_anthropic.ChatAnthropic``
directly. Defaults to Opus 4.6 for analyzers and Sonnet 4.6 for
``meta_analyzer`` (cheaper for the high-volume filter pass), mirroring the
policy used by ``NvInferenceProvider``.
"""

from __future__ import annotations
Expand Down Expand Up @@ -48,11 +49,12 @@ class AnthropicProvider:
}

def resolve_credentials(self) -> tuple[str, str | None] | None:
"""Return ``(api_key, base_url)`` from ``ANTHROPIC_API_KEY``."""
"""Return ``(api_key, base_url)`` from ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_BASE_URL``."""
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
if not api_key:
return None
return api_key, None
base_url = os.environ.get("ANTHROPIC_BASE_URL", "").strip() or None
return api_key, base_url

def create_chat_model(
self,
Expand All @@ -66,11 +68,11 @@ def create_chat_model(
if creds is None:
return None

api_key, _ = creds
api_key, base_url = creds
return ChatAnthropic(
model_name=model,
api_key=SecretStr(api_key),
base_url=ANTHROPIC_BASE_URL,
base_url=base_url or ANTHROPIC_BASE_URL,
max_tokens_to_sample=max_tokens,
timeout=timeout,
stop=None,
Expand Down
6 changes: 5 additions & 1 deletion tests/provider/test_provider_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,15 @@ def test_openai_provider_makes_live_structured_request(
assert result == ProviderResult(ok=True)


def test_anthropic_provider_makes_live_structured_request() -> None:
def test_anthropic_provider_makes_live_structured_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Anthropic provider reaches its default endpoint and returns structured output."""
from skillspector.providers.anthropic import ANTHROPIC_BASE_URL, AnthropicProvider

_skip_without_env("ANTHROPIC_API_KEY")
# This live provider check must hit Anthropic's default base URL, not a proxy.
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)

model = _model_from_env("SKILLSPECTOR_ANTHROPIC_TEST_MODEL", AnthropicProvider.DEFAULT_MODEL)
llm = AnthropicProvider().create_chat_model(model, max_tokens=32, timeout=60)
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_llm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

_LLM_ENV_VARS = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"NVIDIA_INFERENCE_KEY",
Expand Down
22 changes: 20 additions & 2 deletions tests/unit/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
resolve_provider_credentials,
use_provider,
)
from skillspector.providers.anthropic import AnthropicProvider
from skillspector.providers.anthropic import ANTHROPIC_BASE_URL, AnthropicProvider
from skillspector.providers.antigravity_cli import AntigravityCLIProvider
from skillspector.providers.chat_models import create_openai_compatible_chat_model
from skillspector.providers.claude_cli import ClaudeCLIProvider
Expand Down Expand Up @@ -114,6 +114,7 @@ def _clean_provider_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_PROJECT_ID", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.delenv("SKILLSPECTOR_MODEL", raising=False)
monkeypatch.delenv("SKILLSPECTOR_MODEL_REGISTRY", raising=False)
monkeypatch.delenv("SKILLSPECTOR_PROVIDER", raising=False)
Expand Down Expand Up @@ -296,14 +297,31 @@ def test_resolves_anthropic_api_key_without_openai_endpoint(
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x")
creds = AnthropicProvider().resolve_credentials()
assert creds == ("sk-ant-x", None)
assert creds == ("sk-ant-x", None) # None → ChatAnthropic uses api.anthropic.com

def test_honors_anthropic_base_url_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://localhost:8787")
creds = AnthropicProvider().resolve_credentials()
assert creds == ("sk-ant-x", "http://localhost:8787")

def test_creates_native_chat_anthropic(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x")
llm = AnthropicProvider().create_chat_model("claude-opus-4-6", max_tokens=123)
assert isinstance(llm, ChatAnthropic)
assert llm.model == "claude-opus-4-6"
assert llm.max_tokens == 123
# No override → ChatAnthropic points at the default Anthropic endpoint.
assert str(llm.anthropic_api_url).rstrip("/") == ANTHROPIC_BASE_URL.rstrip("/")

def test_create_chat_model_honors_base_url_override(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://localhost:8787")
llm = AnthropicProvider().create_chat_model("claude-opus-4-6", max_tokens=123)
assert isinstance(llm, ChatAnthropic)
assert str(llm.anthropic_api_url).rstrip("/") == "http://localhost:8787"

def test_create_chat_model_returns_none_without_key(self) -> None:
# No ANTHROPIC_API_KEY → no client, signalling the caller to fall back.
Expand Down