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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-...
Expand Down Expand Up @@ -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/<provider>/model_registry.yaml`) with a custom path. | Optional |
| `SKILLSPECTOR_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`). | Optional |
Expand Down
13 changes: 9 additions & 4 deletions src/skillspector/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
"""
Expand Down Expand Up @@ -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", ""):
Expand All @@ -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)."
)


Expand Down
20 changes: 20 additions & 0 deletions src/skillspector/providers/anthropic_proxy/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
16 changes: 16 additions & 0 deletions src/skillspector/providers/anthropic_proxy/model_registry.yaml
Original file line number Diff line number Diff line change
@@ -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
258 changes: 258 additions & 0 deletions src/skillspector/providers/anthropic_proxy/provider.py
Original file line number Diff line number Diff line change
@@ -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 <token>`` 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
Loading