From c8e1626c0c267531dc19a06a93f89ac9b3bc200a Mon Sep 17 00:00:00 2001 From: alenjoses Date: Sun, 21 Jun 2026 11:04:29 +0530 Subject: [PATCH] feat(provider): add anthropic_proxy provider for Vertex-style raw-predict endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `anthropic_proxy` provider that enables SkillSpector to connect to Anthropic Claude models served behind Vertex-style raw-predict endpoints (corporate API gateways, GCP Vertex AI, self-hosted proxies). These endpoints differ from api.anthropic.com in URL structure (model in path), auth mechanism (Bearer token), and body format (anthropic_version in body, no model field). The provider uses custom httpx transports to rewrite ChatAnthropic SDK requests at the HTTP layer, preserving all LangChain features (tool calling, structured output, streaming). Configuration: ANTHROPIC_PROXY_ENDPOINT_URL — full endpoint URL ANTHROPIC_PROXY_API_KEY — Bearer token ANTHROPIC_PROXY_API_VERSION — optional (default: vertex-2023-10-16) Closes NVIDIA/SkillSpector#127 Signed-off-by: Alen Joses R Signed-off-by: alenjoses Co-authored-by: Cursor --- .env.example | 9 +- README.md | 11 + src/skillspector/providers/__init__.py | 13 +- .../providers/anthropic_proxy/__init__.py | 20 ++ .../anthropic_proxy/model_registry.yaml | 16 ++ .../providers/anthropic_proxy/provider.py | 258 ++++++++++++++++++ tests/unit/test_anthropic_proxy_provider.py | 254 +++++++++++++++++ 7 files changed, 576 insertions(+), 5 deletions(-) create mode 100644 src/skillspector/providers/anthropic_proxy/__init__.py create mode 100644 src/skillspector/providers/anthropic_proxy/model_registry.yaml create mode 100644 src/skillspector/providers/anthropic_proxy/provider.py create mode 100644 tests/unit/test_anthropic_proxy_provider.py 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)