diff --git a/.env.example b/.env.example index 24ad4f51..5e90ec6f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ ENV=dev # options: dev|s # Active LLM provider. Selects which provider answers credentials, # metadata, and default-model lookups. Leave unset to default to nv_build. -# Options: openai | anthropic | nv_build +# Options: openai | anthropic | anthropic_proxy | nv_build SKILLSPECTOR_PROVIDER= # Provider credentials — set the one matching SKILLSPECTOR_PROVIDER (or @@ -21,6 +21,13 @@ OPENAI_BASE_URL= # For SKILLSPECTOR_PROVIDER=anthropic. ANTHROPIC_API_KEY= +# For SKILLSPECTOR_PROVIDER=anthropic_proxy (Vertex-style raw-predict proxy). +# Supports corporate API gateways, GCP Vertex AI, and self-hosted proxies. +ANTHROPIC_PROXY_ENDPOINT_URL= +ANTHROPIC_PROXY_API_KEY= +# ANTHROPIC_PROXY_API_VERSION=vertex-2023-10-16 # optional; defaults to vertex-2023-10-16 +# SKILLSPECTOR_SSL_VERIFY=false # set to false for internal/self-signed CAs + # SkillSpector config SKILLSPECTOR_MODEL= # leave empty to use the active provider's bundled default (see README); set to override (e.g. gpt-5.2) # SKILLSPECTOR_MODEL_REGISTRY=./model_registry.yaml # optional override; defaults to each provider's bundled YAML in src/skillspector/providers/ diff --git a/README.md b/README.md index 6984998b..8e6c5dfe 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ inference gateways. | ---------- | ---- | ---- | ---- | | `openai` | `OPENAI_API_KEY` (+ optional `OPENAI_BASE_URL`) | api.openai.com (or any OpenAI-compatible URL) | `gpt-5.4` | | `anthropic` | `ANTHROPIC_API_KEY` | api.anthropic.com | `claude-opus-4-6` | +| `anthropic_proxy` | `ANTHROPIC_PROXY_API_KEY` + `ANTHROPIC_PROXY_ENDPOINT_URL` | Any Vertex-style raw-predict proxy | `claude-sonnet-4-6` | | `nv_build` | `NVIDIA_INFERENCE_KEY` | build.nvidia.com | `deepseek-ai/deepseek-v4-flash` | ```bash @@ -161,6 +162,13 @@ export SKILLSPECTOR_PROVIDER=anthropic export ANTHROPIC_API_KEY=sk-ant-... skillspector scan ./my-skill/ +# Anthropic via Vertex-style proxy (corporate gateways, GCP Vertex AI) +export SKILLSPECTOR_PROVIDER=anthropic_proxy +export ANTHROPIC_PROXY_ENDPOINT_URL=https://my-gateway.example.com/models/claude-sonnet-4-6:streamRawPredict +export ANTHROPIC_PROXY_API_KEY=your-bearer-token +export SKILLSPECTOR_MODEL=claude-sonnet-4-6 +skillspector scan ./my-skill/ + # NVIDIA build.nvidia.com export SKILLSPECTOR_PROVIDER=nv_build export NVIDIA_INFERENCE_KEY=nvapi-... @@ -401,6 +409,9 @@ Issues (2) | `OPENAI_API_KEY` | Credential for the OpenAI provider (`SKILLSPECTOR_PROVIDER=openai`). Also serves as the tier-2 fallback in the credential waterfall when the active provider returns no credentials. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=openai` | | `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | Optional | | `ANTHROPIC_API_KEY` | Credential for the Anthropic provider (`SKILLSPECTOR_PROVIDER=anthropic`). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=anthropic` | +| `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | +| `ANTHROPIC_PROXY_API_KEY` | Bearer token for the Anthropic proxy provider. | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | +| `ANTHROPIC_PROXY_API_VERSION` | `anthropic_version` value sent in the request body (default: `vertex-2023-10-16`). | Optional | | `SKILLSPECTOR_MODEL` | Override the active provider's default model. See the LLM Analysis table for each provider's default. | Optional | | `SKILLSPECTOR_MODEL_REGISTRY` | Override the bundled per-provider YAML registry (`src/skillspector/providers//model_registry.yaml`) with a custom path. | Optional | | `SKILLSPECTOR_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`). | Optional | diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index bf1522e6..307ae6a5 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -22,9 +22,10 @@ Selection happens via the ``SKILLSPECTOR_PROVIDER`` env var: - openai → OpenAIProvider (api.openai.com) - anthropic → AnthropicProvider (api.anthropic.com) - nv_build → NvBuildProvider (build.nvidia.com) + openai → OpenAIProvider (api.openai.com) + anthropic → AnthropicProvider (api.anthropic.com) + anthropic_proxy → AnthropicProxyProvider (Vertex-style raw-predict proxy) + nv_build → NvBuildProvider (build.nvidia.com) When unset, the selector defaults to ``nv_build``. """ @@ -64,6 +65,10 @@ def _select_active_provider() -> LLMProvider: from .anthropic import AnthropicProvider return AnthropicProvider() + if name == "anthropic_proxy": + from .anthropic_proxy import AnthropicProxyProvider + + return AnthropicProxyProvider() if name == "nv_build": return NvBuildProvider() if name in ("nv_inference", ""): @@ -78,7 +83,7 @@ def _select_active_provider() -> LLMProvider: raise ValueError( f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. " - "Expected one of: openai, anthropic, nv_build (or unset)." + "Expected one of: openai, anthropic, anthropic_proxy, nv_build (or unset)." ) diff --git a/src/skillspector/providers/anthropic_proxy/__init__.py b/src/skillspector/providers/anthropic_proxy/__init__.py new file mode 100644 index 00000000..b8850cf7 --- /dev/null +++ b/src/skillspector/providers/anthropic_proxy/__init__.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Anthropic proxy provider package (Vertex-style raw-predict endpoints).""" + +from .provider import REGISTRY_PATH, AnthropicProxyProvider + +__all__ = ["REGISTRY_PATH", "AnthropicProxyProvider"] diff --git a/src/skillspector/providers/anthropic_proxy/model_registry.yaml b/src/skillspector/providers/anthropic_proxy/model_registry.yaml new file mode 100644 index 00000000..e6a076e5 --- /dev/null +++ b/src/skillspector/providers/anthropic_proxy/model_registry.yaml @@ -0,0 +1,16 @@ +# Token-budget metadata for the Anthropic proxy provider. +# Same models/budgets as the standard Anthropic provider — the proxy +# forwards to the same Claude backends. + +models: + "claude-opus-4-5": + context_length: 200000 + max_output_tokens: 64000 + + "claude-sonnet-4-6": + context_length: 1000000 + max_output_tokens: 128000 + + "claude-opus-4-6": + context_length: 1000000 + max_output_tokens: 128000 diff --git a/src/skillspector/providers/anthropic_proxy/provider.py b/src/skillspector/providers/anthropic_proxy/provider.py new file mode 100644 index 00000000..e17f5d35 --- /dev/null +++ b/src/skillspector/providers/anthropic_proxy/provider.py @@ -0,0 +1,258 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Anthropic proxy provider — Claude models via Vertex-style raw-predict endpoints. + +Supports corporate API gateways, GCP Vertex AI, and self-hosted proxies +that expose Anthropic models through a raw-predict interface rather than +the standard ``api.anthropic.com`` Messages API. + +These endpoints differ from the standard Anthropic API in three ways: + +1. **URL**: Full path includes model name + ``:streamRawPredict`` suffix + rather than ``/v1/messages``. +2. **Auth**: ``Authorization: Bearer `` instead of ``x-api-key``. +3. **Body**: Contains an ``anthropic_version`` field (no ``model`` field; + model is encoded in the URL path). + +This provider uses custom httpx transports to rewrite requests from the +Anthropic SDK into the proxy's expected format, preserving all LangChain +``ChatAnthropic`` features (tool calling, structured output, streaming). + +Environment variables:: + + ANTHROPIC_PROXY_ENDPOINT_URL Full endpoint URL (including model path) + ANTHROPIC_PROXY_API_KEY Bearer token for the Authorization header + ANTHROPIC_PROXY_API_VERSION anthropic_version value (default: vertex-2023-10-16) +""" + +from __future__ import annotations + +import json +import os +from functools import cached_property +from pathlib import Path +from typing import Any + +import anthropic +import httpx +from langchain_anthropic import ChatAnthropic +from langchain_core.language_models.chat_models import BaseChatModel +from pydantic import SecretStr + +from skillspector.providers import registry + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + +DEFAULT_API_VERSION = "vertex-2023-10-16" + + +def _get_api_version() -> str: + """Return the ``anthropic_version`` value from env or the default.""" + return os.environ.get("ANTHROPIC_PROXY_API_VERSION", "").strip() or DEFAULT_API_VERSION + + +class _ProxyTransport(httpx.BaseTransport): + """Sync httpx transport that rewrites Anthropic SDK requests for the proxy.""" + + def __init__( + self, + endpoint_url: str, + bearer_token: str, + *, + verify: bool | str = True, + ): + self._endpoint_url = endpoint_url + self._bearer_token = bearer_token + self._inner = httpx.HTTPTransport(verify=verify) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + body["anthropic_version"] = _get_api_version() + body.pop("model", None) + encoded_body = json.dumps(body).encode("utf-8") + + headers = httpx.Headers( + { + k: v + for k, v in request.headers.items() + if k.lower() not in ("x-api-key", "anthropic-version", "host", "content-length") + } + ) + headers["authorization"] = f"Bearer {self._bearer_token}" + headers["content-length"] = str(len(encoded_body)) + + new_request = httpx.Request( + method=request.method, + url=self._endpoint_url, + headers=headers, + content=encoded_body, + ) + return self._inner.handle_request(new_request) + + def close(self) -> None: + self._inner.close() + + +class _ProxyAsyncTransport(httpx.AsyncBaseTransport): + """Async httpx transport that rewrites Anthropic SDK requests for the proxy.""" + + def __init__( + self, + endpoint_url: str, + bearer_token: str, + *, + verify: bool | str = True, + ): + self._endpoint_url = endpoint_url + self._bearer_token = bearer_token + self._inner = httpx.AsyncHTTPTransport(verify=verify) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + body["anthropic_version"] = _get_api_version() + body.pop("model", None) + encoded_body = json.dumps(body).encode("utf-8") + + headers = httpx.Headers( + { + k: v + for k, v in request.headers.items() + if k.lower() not in ("x-api-key", "anthropic-version", "host", "content-length") + } + ) + headers["authorization"] = f"Bearer {self._bearer_token}" + headers["content-length"] = str(len(encoded_body)) + + new_request = httpx.Request( + method=request.method, + url=self._endpoint_url, + headers=headers, + content=encoded_body, + ) + return await self._inner.handle_async_request(new_request) + + async def aclose(self) -> None: + await self._inner.aclose() + + +def _ssl_verify() -> bool | str: + """Return the SSL verification setting from ``SKILLSPECTOR_SSL_VERIFY``.""" + val = os.environ.get("SKILLSPECTOR_SSL_VERIFY", "").strip().lower() + if val == "false": + return False + if val and val != "true": + return val + return True + + +class _ChatAnthropicProxy(ChatAnthropic): + """``ChatAnthropic`` subclass that routes requests through a custom proxy.""" + + _proxy_endpoint_url: str = "" + _proxy_bearer_token: str = "" + _proxy_ssl_verify: bool | str = True + + def __init__(self, *, proxy_endpoint_url: str, proxy_bearer_token: str, **kwargs: Any): + super().__init__(**kwargs) + object.__setattr__(self, "_proxy_endpoint_url", proxy_endpoint_url) + object.__setattr__(self, "_proxy_bearer_token", proxy_bearer_token) + object.__setattr__(self, "_proxy_ssl_verify", _ssl_verify()) + + @cached_property + def _client(self) -> anthropic.Client: # type: ignore[override] + params = self._client_params + transport = _ProxyTransport( + self._proxy_endpoint_url, + self._proxy_bearer_token, + verify=self._proxy_ssl_verify, + ) + http_client = httpx.Client(transport=transport) + return anthropic.Client( + api_key=params["api_key"], + base_url=params.get("base_url"), + max_retries=params.get("max_retries", 2), + default_headers=params.get("default_headers"), + timeout=params.get("timeout"), + http_client=http_client, + ) + + @cached_property + def _async_client(self) -> anthropic.AsyncClient: # type: ignore[override] + params = self._client_params + transport = _ProxyAsyncTransport( + self._proxy_endpoint_url, + self._proxy_bearer_token, + verify=self._proxy_ssl_verify, + ) + http_client = httpx.AsyncClient(transport=transport) + return anthropic.AsyncClient( + api_key=params["api_key"], + base_url=params.get("base_url"), + max_retries=params.get("max_retries", 2), + default_headers=params.get("default_headers"), + timeout=params.get("timeout"), + http_client=http_client, + ) + + +class AnthropicProxyProvider: + """Anthropic proxy provider for Vertex-style raw-predict endpoints.""" + + DEFAULT_MODEL = "claude-sonnet-4-6" + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Return ``(api_key, endpoint_url)`` from proxy env vars.""" + api_key = os.environ.get("ANTHROPIC_PROXY_API_KEY", "").strip() + endpoint_url = os.environ.get("ANTHROPIC_PROXY_ENDPOINT_URL", "").strip() + if not api_key or not endpoint_url: + return None + return api_key, endpoint_url + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatAnthropic`` with custom transport for the proxy.""" + creds = self.resolve_credentials() + if creds is None: + return None + + bearer_token, endpoint_url = creds + + return _ChatAnthropicProxy( + proxy_endpoint_url=endpoint_url, + proxy_bearer_token=bearer_token, + model_name=model, + anthropic_api_key=SecretStr("anthropic-proxy-placeholder"), + max_tokens=max_tokens, + default_request_timeout=timeout, + stop_sequences=None, + ) + + def get_context_length(self, model: str) -> int | None: + return registry.lookup_context_length(REGISTRY_PATH, model) + + def get_max_output_tokens(self, model: str) -> int | None: + return registry.lookup_max_output_tokens(REGISTRY_PATH, model) + + def resolve_model(self, slot: str = "default") -> str: + """Resolve model: ``SKILLSPECTOR_MODEL`` env > slot default > DEFAULT_MODEL.""" + user_input = os.environ.get("SKILLSPECTOR_MODEL", "").strip() + return user_input or self.SLOT_DEFAULTS.get(slot, "") or self.DEFAULT_MODEL diff --git a/tests/unit/test_anthropic_proxy_provider.py b/tests/unit/test_anthropic_proxy_provider.py new file mode 100644 index 00000000..c3a909fb --- /dev/null +++ b/tests/unit/test_anthropic_proxy_provider.py @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the AnthropicProxyProvider (Vertex-style raw-predict endpoints).""" + +from __future__ import annotations + +import json + +import httpx +import pytest +from langchain_anthropic import ChatAnthropic + +from skillspector.providers import create_chat_model, get_metadata_provider, registry +from skillspector.providers.anthropic_proxy import AnthropicProxyProvider +from skillspector.providers.anthropic_proxy.provider import ( + DEFAULT_API_VERSION, + _ChatAnthropicProxy, + _get_api_version, + _ProxyTransport, +) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch: pytest.MonkeyPatch): + """Isolate provider-related env vars for each test.""" + monkeypatch.delenv("ANTHROPIC_PROXY_ENDPOINT_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_PROXY_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_PROXY_API_VERSION", raising=False) + monkeypatch.delenv("SKILLSPECTOR_MODEL", raising=False) + monkeypatch.delenv("SKILLSPECTOR_PROVIDER", raising=False) + monkeypatch.delenv("SKILLSPECTOR_SSL_VERIFY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("NVIDIA_INFERENCE_KEY", raising=False) + registry._load.cache_clear() + yield + registry._load.cache_clear() + + +class TestAnthropicProxyProviderCredentials: + """Credential resolution tests.""" + + def test_returns_none_without_env_vars(self) -> None: + assert AnthropicProxyProvider().resolve_credentials() is None + + def test_returns_none_with_only_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_PROXY_API_KEY", "token-123") + assert AnthropicProxyProvider().resolve_credentials() is None + + def test_returns_none_with_only_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_PROXY_ENDPOINT_URL", "https://proxy.example.com/predict") + assert AnthropicProxyProvider().resolve_credentials() is None + + def test_resolves_credentials_with_both_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_PROXY_API_KEY", "bearer-tok") + monkeypatch.setenv("ANTHROPIC_PROXY_ENDPOINT_URL", "https://proxy.example.com/predict") + creds = AnthropicProxyProvider().resolve_credentials() + assert creds == ("bearer-tok", "https://proxy.example.com/predict") + + +class TestAnthropicProxyProviderChatModel: + """Chat model creation tests.""" + + def test_create_chat_model_returns_none_without_creds(self) -> None: + result = AnthropicProxyProvider().create_chat_model("claude-sonnet-4-6", max_tokens=1024) + assert result is None + + def test_creates_chat_anthropic_subclass(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_PROXY_API_KEY", "bearer-tok") + monkeypatch.setenv( + "ANTHROPIC_PROXY_ENDPOINT_URL", + "https://proxy.example.com/models/claude-sonnet-4-6:streamRawPredict", + ) + llm = AnthropicProxyProvider().create_chat_model("claude-sonnet-4-6", max_tokens=4096) + assert isinstance(llm, ChatAnthropic) + assert isinstance(llm, _ChatAnthropicProxy) + assert llm.model == "claude-sonnet-4-6" + assert llm.max_tokens == 4096 + + +class TestAnthropicProxyProviderMetadata: + """Token-budget metadata and model resolution tests.""" + + def test_metadata_known_model(self) -> None: + provider = AnthropicProxyProvider() + assert provider.get_context_length("claude-sonnet-4-6") == 1_000_000 + assert provider.get_max_output_tokens("claude-sonnet-4-6") == 128_000 + + def test_metadata_unknown_model_returns_none(self) -> None: + provider = AnthropicProxyProvider() + assert provider.get_context_length("unknown-model") is None + assert provider.get_max_output_tokens("unknown-model") is None + + def test_default_model(self) -> None: + assert AnthropicProxyProvider().resolve_model() == "claude-sonnet-4-6" + + def test_resolve_model_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_MODEL", "claude-opus-4-6") + assert AnthropicProxyProvider().resolve_model() == "claude-opus-4-6" + assert AnthropicProxyProvider().resolve_model("meta_analyzer") == "claude-opus-4-6" + + +class TestProxyTransport: + """Transport request-rewriting tests.""" + + def _make_request( + self, body: dict, *, monkeypatch: pytest.MonkeyPatch | None = None + ) -> tuple[httpx.Request, dict]: + """Create a transport and capture the rewritten request. + + Returns the rewritten httpx.Request and the decoded body. + """ + transport = _ProxyTransport( + endpoint_url="https://proxy.example.com/models/claude:predict", + bearer_token="my-token", + verify=False, + ) + + original_request = httpx.Request( + method="POST", + url="https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": "sk-ant-secret", + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + content=json.dumps(body).encode("utf-8"), + ) + + captured: list[httpx.Request] = [] + + class _CapturingTransport(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + status_code=200, + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + transport._inner = _CapturingTransport() + transport.handle_request(original_request) + + assert len(captured) == 1 + rewritten = captured[0] + rewritten_body = json.loads(rewritten.content) + return rewritten, rewritten_body + + def test_rewrites_url(self) -> None: + rewritten, _ = self._make_request( + {"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 100} + ) + assert str(rewritten.url) == "https://proxy.example.com/models/claude:predict" + + def test_removes_model_from_body(self) -> None: + _, body = self._make_request( + {"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 100} + ) + assert "model" not in body + + def test_injects_anthropic_version(self) -> None: + _, body = self._make_request( + {"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 100} + ) + assert body["anthropic_version"] == DEFAULT_API_VERSION + + def test_replaces_auth_header(self) -> None: + rewritten, _ = self._make_request( + {"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 100} + ) + assert rewritten.headers["authorization"] == "Bearer my-token" + assert "x-api-key" not in rewritten.headers + assert "anthropic-version" not in rewritten.headers + + def test_preserves_content_type(self) -> None: + rewritten, _ = self._make_request( + {"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 100} + ) + assert rewritten.headers["content-type"] == "application/json" + + def test_preserves_other_body_fields(self) -> None: + _, body = self._make_request( + { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 200, + "temperature": 0.5, + } + ) + assert body["messages"] == [{"role": "user", "content": "hi"}] + assert body["max_tokens"] == 200 + assert body["temperature"] == 0.5 + + +class TestApiVersionConfiguration: + """Tests for ANTHROPIC_PROXY_API_VERSION env var.""" + + def test_default_api_version(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ANTHROPIC_PROXY_API_VERSION", raising=False) + assert _get_api_version() == "vertex-2023-10-16" + + def test_custom_api_version(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_PROXY_API_VERSION", "bedrock-2023-05-31") + assert _get_api_version() == "bedrock-2023-05-31" + + def test_empty_string_falls_back_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ANTHROPIC_PROXY_API_VERSION", " ") + assert _get_api_version() == DEFAULT_API_VERSION + + +class TestProviderSelection: + """Integration with the provider selector.""" + + def test_select_anthropic_proxy(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic_proxy") + assert isinstance(get_metadata_provider(), AnthropicProxyProvider) + + def test_create_chat_model_via_selector(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic_proxy") + monkeypatch.setenv("ANTHROPIC_PROXY_API_KEY", "tok") + monkeypatch.setenv("ANTHROPIC_PROXY_ENDPOINT_URL", "https://proxy.example.com/predict") + llm = create_chat_model("claude-sonnet-4-6", max_tokens=1024) + assert isinstance(llm, _ChatAnthropicProxy) + + def test_falls_back_to_openai_when_proxy_unconfigured( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When anthropic_proxy is selected but not configured, falls back to OpenAI.""" + from langchain_openai import ChatOpenAI + + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic_proxy") + monkeypatch.setenv("OPENAI_API_KEY", "sk-fallback") + llm = create_chat_model("gpt-5.4", max_tokens=1024) + assert isinstance(llm, ChatOpenAI)