diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5cca6836 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.venv +env +venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +htmlcov +dist +build +*.egg-info +.env +.env.* +!.env.example +*.log +tmp +temp +.skillspector +.DS_Store diff --git a/.gitignore b/.gitignore index 4e703703..3bedddb4 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ venv.bak/ tmp/ temp/ .skillspector/ +.provider-test-missing-keys # API Keys (never commit!) .env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..592e2eee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim-bookworm AS builder + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src/ src/ +RUN python -m venv .venv +RUN .venv/bin/pip install --no-cache-dir . + +FROM python:3.12-slim-bookworm + +RUN apt-get update \ + && apt-get install --no-install-recommends -y git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/.venv /app/.venv + +ENV PATH="/app/.venv/bin:$PATH" +WORKDIR /scan + +ENTRYPOINT ["skillspector"] diff --git a/Makefile b/Makefile index fc5f4c95..c84302c6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev langgraph-dev test test-unit test-integration test-cov test-ci lint lint-fix format format-check clean build +.PHONY: help install install-dev langgraph-dev test test-unit test-provider openai anthropic nv_build test-integration test-cov test-ci lint lint-fix format format-check clean build docker-build docker-smoke # Prefer uv if available, else use pip (set when Makefile is parsed) UV := $(shell command -v uv 2>/dev/null) @@ -8,6 +8,24 @@ UV := $(shell command -v uv 2>/dev/null) # make langgraph-dev LANGGRAPH_STUDIO_URL=https://your-studio.example LANGGRAPH_STUDIO_URL = https://smith.langchain.com +PROVIDER_TEST_SELECTION := $(filter openai anthropic nv_build,$(MAKECMDGOALS)) +ifneq ($(PROVIDER_TEST_SELECTION),) +PROVIDER_TEST_PROVIDERS := $(PROVIDER_TEST_SELECTION) +PROVIDER_TEST_TARGETS := +ifneq ($(filter openai,$(PROVIDER_TEST_SELECTION)),) +PROVIDER_TEST_TARGETS += tests/provider/test_provider_endpoint.py::test_openai_provider_makes_live_structured_request +endif +ifneq ($(filter anthropic,$(PROVIDER_TEST_SELECTION)),) +PROVIDER_TEST_TARGETS += tests/provider/test_provider_endpoint.py::test_anthropic_provider_makes_live_structured_request +endif +ifneq ($(filter nv_build,$(PROVIDER_TEST_SELECTION)),) +PROVIDER_TEST_TARGETS += tests/provider/test_provider_endpoint.py::test_nv_build_provider_makes_live_structured_request +endif +else +PROVIDER_TEST_PROVIDERS := openai anthropic nv_build +PROVIDER_TEST_TARGETS := tests/provider +endif + # Default target. All targets assume the virtual env is already created and activated. help: @echo "Available targets (venv must be created and activated first):" @@ -16,6 +34,7 @@ help: @echo " make langgraph-dev - Run LangGraph dev server (Studio at \$$LANGGRAPH_STUDIO_URL)" @echo " make test - Run unit + integration tests" @echo " make test-unit - Run unit tests only (no LLM calls)" + @echo " make test-provider [openai|anthropic|nv_build] - Run live provider tests" @echo " make test-integration - Run integration tests only (invokes full graph, may call LLMs)" @echo " make test-cov - Run tests with coverage report" @echo " make lint - Run linters (ruff only)" @@ -24,6 +43,8 @@ help: @echo " make format-check - Check code formatting with ruff" @echo " make clean - Remove build artifacts and cache files" @echo " make build - Build the package" + @echo " make docker-build - Build the Docker image" + @echo " make docker-smoke - Build and smoke test the Docker image" install: @if [ -n "$(UV)" ]; then uv sync; else pip install -e .; fi @@ -38,9 +59,39 @@ langgraph-dev: # Run unit + integration tests test: test-unit test-integration -# Run unit tests only (excludes integration marker) +# Run unit tests only (excludes provider and integration markers) test-unit: - pytest -m "not integration" tests/ + pytest -m "not integration and not provider" tests/ + +# Run live provider tests (requires provider-specific API keys) +test-provider: + @missing_keys=0; \ + if [ -n "$${PROVIDER_TEST_MISSING_KEYS_FILE:-}" ]; then \ + rm -f "$$PROVIDER_TEST_MISSING_KEYS_FILE"; \ + fi; \ + for provider in $(PROVIDER_TEST_PROVIDERS); do \ + case "$$provider" in \ + openai) env_name=OPENAI_API_KEY; label=OpenAI ;; \ + anthropic) env_name=ANTHROPIC_API_KEY; label=Anthropic ;; \ + nv_build) env_name=NVIDIA_INFERENCE_KEY; label="NV Build" ;; \ + esac; \ + eval "value=\$${$${env_name}:-}"; \ + if [ -z "$$value" ]; then \ + echo "WARNING: $$env_name is not set; $$label provider test will be skipped"; \ + missing_keys=1; \ + fi; \ + done; \ + pytest -m provider $(PROVIDER_TEST_TARGETS); \ + pytest_status=$$?; \ + if [ "$$pytest_status" -ne 0 ]; then \ + exit "$$pytest_status"; \ + fi; \ + if [ "$$missing_keys" -ne 0 ] && [ -n "$${PROVIDER_TEST_MISSING_KEYS_FILE:-}" ]; then \ + printf "missing provider keys\n" > "$$PROVIDER_TEST_MISSING_KEYS_FILE"; \ + fi + +openai anthropic nv_build: + @: # Run integration tests only (invokes full graph, may call LLMs) test-integration: @@ -48,11 +99,11 @@ test-integration: # Run tests with coverage test-cov: - pytest --cov=src/skillspector --cov-report=html --cov-report=term-missing tests/ + pytest -m "not integration and not provider" --cov=src/skillspector --cov-report=html --cov-report=term-missing tests/ # Run tests with coverage for CI (Cobertura XML + terminal) test-ci: - pytest --cov=src/skillspector --cov-report=term-missing --cov-report=xml tests/ + pytest -m "not integration and not provider" --cov=src/skillspector --cov-report=term-missing --cov-report=xml tests/ # Run linters (fast: ruff only) lint: @@ -94,3 +145,11 @@ clean: build: clean python -m build +# Build the Docker image +docker-build: + docker build -t skillspector . + +# Build and smoke test the Docker image +docker-smoke: docker-build + tests/docker/smoke.sh + diff --git a/README.md b/README.md index 4ef9c6cf..6984998b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,64 @@ make install make install-dev ``` +### Docker (no Python required) + +Run SkillSpector without installing Python by building it locally from the included [Dockerfile](Dockerfile). The image is based on the Docker Official Python `3.12-slim-bookworm` image. + +**Build the image:** + +```bash +make docker-build +# or: docker build -t skillspector . +``` + +**Scan a local directory** by mounting your current directory into `/scan`, the container's working directory: + +```bash +docker run --rm -v "$PWD:/scan" skillspector scan ./my-skill/ --no-llm +``` + +**Scan with LLM analysis** by passing credentials with a local `.env` file: + +```bash +cat > .env <<'EOF' +SKILLSPECTOR_PROVIDER=anthropic +ANTHROPIC_API_KEY=sk-ant-... +EOF +``` + +```bash +docker run --rm \ + -v "$PWD:/scan" \ + --env-file .env \ + skillspector scan ./my-skill/ +``` + +Or pass credentials directly from your shell environment: + +```bash +docker run --rm \ + -v "$PWD:/scan" \ + -e SKILLSPECTOR_PROVIDER=anthropic \ + -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ + skillspector scan ./my-skill/ +``` + +**Write a report to the host filesystem** by writing to the mounted directory: + +```bash +docker run --rm \ + -v "$PWD:/scan" \ + skillspector scan ./my-skill/ --no-llm --format json --output report.json +``` + +**Optional alias** for repeated static scans: + +```bash +alias skillspector-docker='docker run --rm -v "$PWD:/scan" skillspector' +skillspector-docker scan ./my-skill/ --no-llm +``` + ### Basic Usage ```bash @@ -87,7 +145,7 @@ local OpenAI-compatible servers (Ollama, vLLM, llama.cpp) and managed inference gateways. | Provider (`SKILLSPECTOR_PROVIDER`) | Credential env var | Endpoint | Default model | -|----------|----|----|----| +| ---------- | ---- | ---- | ---- | | `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` | | `nv_build` | `NVIDIA_INFERENCE_KEY` | build.nvidia.com | `deepseek-ai/deepseek-v4-flash` | @@ -344,7 +402,7 @@ Issues (2) | `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` | | `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/.yaml`) with a custom path. | 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 | ### CLI Options diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0795f093..82387fae 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -267,6 +267,19 @@ Copy [.env.example](../.env.example) to `.env` in the project root and set value | `ANTHROPIC_API_KEY` | Credential for `SKILLSPECTOR_PROVIDER=anthropic`. | `sk-ant-...` | | `SKILLSPECTOR_MODEL` | Override the active provider's bundled default model (see [README.md](../README.md) for per-provider defaults). | `gpt-5.2` | +### Live provider tests + +The manual `test-provider` CI job and local `make test-provider` target perform live requests against provider default endpoints. Missing provider keys print a `WARNING:` line before pytest runs and skip that provider. In CI, missing keys also make the manual job exit with the configured warning code so GitLab displays the job as passed with warnings; if a key is present but invalid, or the provider request fails, the corresponding test fails. + +| Command | Required env var | Default URL | Optional model override | +|---------|------------------|-------------|-------------------------| +| `make test-provider openai` | `OPENAI_API_KEY` | `https://api.openai.com/v1` | `SKILLSPECTOR_OPENAI_TEST_MODEL` | +| `make test-provider anthropic` | `ANTHROPIC_API_KEY` | `https://api.anthropic.com` | `SKILLSPECTOR_ANTHROPIC_TEST_MODEL` | +| `make test-provider nv_build` | `NVIDIA_INFERENCE_KEY` | `https://integrate.api.nvidia.com/v1` | `SKILLSPECTOR_NV_BUILD_TEST_MODEL` | +| `make test-provider` | Any/all of the provider keys above | All provider default URLs above | Any/all provider model overrides above | + +Base URL env vars are not needed for live provider tests; the tests intentionally use provider defaults. + ### Constants, token budgets, and LLM - **Constants** ([constants.py](../src/skillspector/constants.py)): `_SKILLSPECTOR_DEFAULT_MODEL`, `MODEL_CONFIG` (per-node model selection), `MAX_INPUT_TOKENS_PCT` (0.75), `DEFAULT_CONTEXT_LENGTH` (128k fallback). diff --git a/pyproject.toml b/pyproject.toml index a54ae966..fd81d955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "hatchling.build" [project] name = "skillspector" -version = "2.1.2" +version = "2.2.3" description = "SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring." readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.12" +requires-python = ">=3.12,<3.15" keywords = [ "security", "ai-agents", @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Security", "Topic :: Software Development :: Quality Assurance", ] @@ -38,6 +39,7 @@ dependencies = [ "openai>=2.25.0", "langgraph>=1.0.10", "langgraph-cli[inmem]>=0.4.14", + "langchain-anthropic>=1.4.5", "langchain-core>=1.2.17", "langchain-openai>=1.1.10", "langsmith>=0.7.30", @@ -97,5 +99,8 @@ source = ["src/skillspector"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" -markers = ["integration: end-to-end tests that invoke the full graph (may call LLMs)"] -addopts = "-m 'not integration'" +markers = [ + "integration: end-to-end tests that invoke the full graph (may call LLMs)", + "provider: live OpenAI/Anthropic/NVIDIA Build provider endpoint tests", +] +addopts = "-m 'not integration and not provider'" diff --git a/src/skillspector/llm_analyzer_base.py b/src/skillspector/llm_analyzer_base.py index a678e4ec..aa3e7e9a 100644 --- a/src/skillspector/llm_analyzer_base.py +++ b/src/skillspector/llm_analyzer_base.py @@ -32,6 +32,7 @@ from dataclasses import dataclass, field from typing import Literal +from langchain_core.messages import BaseMessage from pydantic import BaseModel, Field from skillspector.llm_utils import get_chat_model @@ -191,6 +192,13 @@ def number_lines(content: str, start_line: int = 1) -> str: return "\n".join(f"L{start_line + i:0>{width}}: {line}" for i, line in enumerate(lines)) +def _message_text(response: object) -> str: + """Extract provider-normalized text from a LangChain chat response.""" + if not isinstance(response, BaseMessage): + raise TypeError(f"Expected BaseMessage from chat model, got {type(response).__name__}") + return str(response.text) + + BASE_ANALYSIS_PROMPT = """\ {analyzer_prompt} @@ -355,7 +363,7 @@ def run_batches( if self._structured_llm: response = self._structured_llm.invoke(prompt) else: - response = self._llm.invoke(prompt).content + response = _message_text(self._llm.invoke(prompt)) logger.debug("LLM response for %s", batch.file_label) parsed = self.parse_response(response, batch) results.append((batch, parsed)) @@ -390,7 +398,7 @@ async def _process(batch: Batch) -> tuple[Batch, list]: if self._structured_llm: response = await self._structured_llm.ainvoke(prompt) else: - response = (await self._llm.ainvoke(prompt)).content + response = _message_text(await self._llm.ainvoke(prompt)) logger.debug("LLM response for %s", batch.file_label) return (batch, self.parse_response(response, batch)) diff --git a/src/skillspector/llm_utils.py b/src/skillspector/llm_utils.py index 1e03fc18..ab6e5518 100644 --- a/src/skillspector/llm_utils.py +++ b/src/skillspector/llm_utils.py @@ -13,29 +13,33 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Shared LLM utilities (OpenAI-compatible chat models). +"""Shared LLM utilities. Credentials are resolved in this order: - 1. The active NVIDIA provider (see :mod:`skillspector.providers`) — - reads ``NVIDIA_INFERENCE_KEY`` and supplies the matching endpoint. + 1. The active SkillSpector provider (see :mod:`skillspector.providers`) — + reads its own credential env var and supplies the matching client. 2. ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL`` (the langchain-openai defaults). There is no SkillSpector-specific credential env var: setting ``NVIDIA_INFERENCE_KEY`` configures whichever NVIDIA endpoint the -deployment ships with, and any other OpenAI-compatible endpoint is -configured via the standard ``OPENAI_*`` envs. +deployment ships with, Anthropic reads ``ANTHROPIC_API_KEY``, and any +other OpenAI-compatible endpoint is configured via the standard +``OPENAI_*`` envs. """ from __future__ import annotations -import os - -from langchain_openai import ChatOpenAI +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import BaseMessage from skillspector.constants import MODEL_CONFIG from skillspector.model_info import get_max_input_tokens, get_max_output_tokens -from skillspector.providers import resolve_provider_credentials +from skillspector.providers import ( + create_chat_model, + raise_no_llm_api_key_configured, + resolve_chat_model_credentials, +) def _resolve_llm_credentials() -> tuple[str, str | None]: @@ -47,21 +51,10 @@ def _resolve_llm_credentials() -> tuple[str, str | None]: Raises: ValueError: when no API key can be resolved from any source. """ - creds = resolve_provider_credentials() - if creds is not None: - return creds - - resolved_key = os.environ.get("OPENAI_API_KEY", "").strip() - if not resolved_key: - raise ValueError( - "No LLM API key configured. Set the credential env var for the " - "active provider, or set OPENAI_API_KEY (and optionally " - "OPENAI_BASE_URL) to use a standard OpenAI-compatible endpoint. " - "Use --no-llm to skip LLM analysis and run static checks only." - ) - - resolved_base = os.environ.get("OPENAI_BASE_URL", "").strip() or None - return resolved_key, resolved_base + creds = resolve_chat_model_credentials() + if creds is None: + raise_no_llm_api_key_configured() + return creds def is_llm_available() -> tuple[bool, str | None]: @@ -78,26 +71,24 @@ def fetch_model_token_limits(model_label: str) -> tuple[int, int]: return get_max_input_tokens(model_label), get_max_output_tokens(model_label) -def get_chat_model(model: str | None = None) -> ChatOpenAI: - """Return a :class:`ChatOpenAI` configured against the resolved endpoint. +def get_chat_model(model: str | None = None) -> BaseChatModel: + """Return the active provider's native LangChain chat model. Raises: ValueError: when no API key is configured (see ``is_llm_available``). """ - resolved_key, resolved_base = _resolve_llm_credentials() model = model or MODEL_CONFIG["default"] - - return ChatOpenAI( + return create_chat_model( model=model, - base_url=resolved_base, - api_key=resolved_key, max_tokens=get_max_output_tokens(model), timeout=120, ) def chat_completion(prompt: str, *, model: str | None = None) -> str: - """Request a single chat completion and return the assistant content.""" + """Request a single chat completion and return the assistant text.""" llm = get_chat_model(model=model) response = llm.invoke(prompt) - return response.content or "" + if not isinstance(response, BaseMessage): + raise TypeError(f"Expected BaseMessage from chat model, got {type(response).__name__}") + return str(response.text) diff --git a/src/skillspector/nodes/build_context.py b/src/skillspector/nodes/build_context.py index 694a0487..ed329143 100644 --- a/src/skillspector/nodes/build_context.py +++ b/src/skillspector/nodes/build_context.py @@ -61,14 +61,17 @@ ) -def _resolve_skill_dir(state: SkillspectorState) -> Path | None: - """Resolve state skill_path to an existing directory Path, or None if missing/invalid.""" +def _resolve_skill_dir(state: SkillspectorState) -> Path: + """Resolve state skill_path to an existing directory Path.""" skill_path = state.get("skill_path") if not skill_path or not isinstance(skill_path, str) or not skill_path.strip(): - return None - resolved = Path(skill_path).resolve() + raise ValueError("skill_path is required; provide input_path or skill_path to scan") + try: + resolved = Path(skill_path).resolve() + except (OSError, RuntimeError) as e: + raise ValueError(f"Invalid skill_path: {skill_path}") from e if not resolved.is_dir(): - return None + raise ValueError(f"Invalid skill_path: {skill_path} is not an existing directory") return resolved @@ -212,32 +215,14 @@ def _parse_manifest(skill_dir: Path) -> dict[str, object]: return {} -def _minimal_update() -> dict[str, object]: - """Return minimal state update when skill_dir is missing or invalid.""" - return { - "components": [], - "file_cache": {}, - "ast_cache": {}, - "manifest": {}, - "previous_manifest": None, - "model_config": MODEL_CONFIG, - "component_metadata": [], - "has_executable_scripts": False, - } - - def build_context(state: SkillspectorState) -> dict[str, object]: """Build flat ScanContext fields from state skill_path (local directory). Resolves skill_path to a directory, walks files, builds file_cache and manifest. Returns only context keys; leaves findings untouched. - If skill_path is missing or not an existing directory, returns minimal - empty context (no exception). + Raises ValueError if skill_path is missing or not an existing directory. """ skill_dir = _resolve_skill_dir(state) - if skill_dir is None: - logger.debug("skill_path missing or not a directory; returning minimal context") - return _minimal_update() components = _walk_skill_files(skill_dir) file_cache = _read_file_cache(skill_dir, components) diff --git a/src/skillspector/nodes/resolve_input.py b/src/skillspector/nodes/resolve_input.py index 4a6d76d8..a4e7555b 100644 --- a/src/skillspector/nodes/resolve_input.py +++ b/src/skillspector/nodes/resolve_input.py @@ -38,7 +38,7 @@ def resolve_input(state: SkillspectorState) -> dict[str, object]: - If state has non-empty input_path: resolve it (Git URL, file URL, zip, file, or directory) and set skill_path. If resolution created a temp dir, set temp_dir_for_cleanup. - Else if state has skill_path: normalize to absolute path and set skill_path. - - Else: set skill_path to None (build_context will return minimal state). + - Else: set skill_path to None (build_context will raise a user-facing error). """ input_path = state.get("input_path") skill_path = state.get("skill_path") diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index 78bdd173..bf1522e6 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -15,8 +15,8 @@ """Pluggable LLM provider package. -The active provider supplies credentials, an OpenAI-compatible base URL, -token-budget metadata, and per-slot default model labels. Each provider +The active provider supplies credentials, token-budget metadata, per-slot +default model labels, and a native LangChain chat model. Each provider is its own subpackage with a ``provider.py`` and a bundled ``model_registry.yaml``. @@ -32,12 +32,27 @@ from __future__ import annotations import os +from typing import NoReturn -from .base import CredentialsProvider, ModelMetadataProvider +from langchain_core.language_models.chat_models import BaseChatModel + +from .base import ChatModelProvider, CredentialsProvider, LLMProvider, ModelMetadataProvider from .nv_build import NvBuildProvider +NO_LLM_API_KEY_MESSAGE = ( + "No LLM API key configured. Set the credential env var for the " + "active provider, or set OPENAI_API_KEY (and optionally " + "OPENAI_BASE_URL) to use a standard OpenAI-compatible endpoint. " + "Use --no-llm to skip LLM analysis and run static checks only." +) + + +def raise_no_llm_api_key_configured() -> NoReturn: + """Raise the shared no-LLM-credentials error.""" + raise ValueError(NO_LLM_API_KEY_MESSAGE) -def _select_active_provider() -> ModelMetadataProvider: + +def _select_active_provider() -> LLMProvider: """Construct the active provider based on ``SKILLSPECTOR_PROVIDER``.""" name = os.environ.get("SKILLSPECTOR_PROVIDER", "").strip().lower() @@ -81,9 +96,62 @@ def resolve_provider_credentials() -> tuple[str, str | None] | None: return _select_active_provider().resolve_credentials() +def _openai_fallback_provider() -> LLMProvider: + """Return the standard OpenAI fallback provider.""" + from .openai import OpenAIProvider + + return OpenAIProvider() + + +def resolve_chat_model_credentials() -> tuple[str, str | None] | None: + """Return credentials used for chat model construction, including fallback.""" + creds = resolve_provider_credentials() + if creds is not None: + return creds + + return _openai_fallback_provider().resolve_credentials() + + +def create_chat_model( + model: str, + *, + max_tokens: int, + timeout: float | None = 120, +) -> BaseChatModel: + """Create the active provider's native LangChain chat model. + + If the active provider is not configured, fall back to standard OpenAI + environment variables. This preserves the historical ``OPENAI_API_KEY`` + escape hatch while letting configured providers choose their own client. + """ + provider = _select_active_provider() + llm = provider.create_chat_model(model, max_tokens=max_tokens, timeout=timeout) + if llm is not None: + return llm + + from .openai import OpenAIProvider + + if not isinstance(provider, OpenAIProvider): + llm = _openai_fallback_provider().create_chat_model( + model, + max_tokens=max_tokens, + timeout=timeout, + ) + if llm is not None: + return llm + + raise_no_llm_api_key_configured() + + __all__ = [ + "ChatModelProvider", "CredentialsProvider", + "LLMProvider", "ModelMetadataProvider", + "NO_LLM_API_KEY_MESSAGE", + "create_chat_model", "get_metadata_provider", + "raise_no_llm_api_key_configured", + "resolve_chat_model_credentials", "resolve_provider_credentials", ] diff --git a/src/skillspector/providers/anthropic/__init__.py b/src/skillspector/providers/anthropic/__init__.py index 0d7050bd..7e59c7ce 100644 --- a/src/skillspector/providers/anthropic/__init__.py +++ b/src/skillspector/providers/anthropic/__init__.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Anthropic provider package (api.anthropic.com OpenAI-compatibility endpoint).""" +"""Anthropic provider package (native api.anthropic.com chat models).""" from .provider import ANTHROPIC_BASE_URL, REGISTRY_PATH, AnthropicProvider diff --git a/src/skillspector/providers/anthropic/provider.py b/src/skillspector/providers/anthropic/provider.py index 0c1c9974..53c38852 100644 --- a/src/skillspector/providers/anthropic/provider.py +++ b/src/skillspector/providers/anthropic/provider.py @@ -15,11 +15,11 @@ """Anthropic provider — Claude models via api.anthropic.com. -Reads ``ANTHROPIC_API_KEY`` for credentials and points -``langchain_openai.ChatOpenAI`` at Anthropic's OpenAI-compatibility -endpoint. 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 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 @@ -27,9 +27,14 @@ import os from pathlib import Path +from langchain_anthropic import ChatAnthropic +from langchain_core.language_models.chat_models import BaseChatModel +from pydantic import SecretStr + from skillspector.providers import registry -ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1/" +# Documented for completeness — ChatAnthropic defaults here when base_url=None. +ANTHROPIC_BASE_URL = "https://api.anthropic.com" REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) @@ -47,7 +52,29 @@ def resolve_credentials(self) -> tuple[str, str | None] | None: api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() if not api_key: return None - return api_key, ANTHROPIC_BASE_URL + return api_key, None + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatAnthropic`` using native Anthropic credentials.""" + creds = self.resolve_credentials() + if creds is None: + return None + + api_key, _ = creds + return ChatAnthropic( + model_name=model, + api_key=SecretStr(api_key), + base_url=ANTHROPIC_BASE_URL, + max_tokens_to_sample=max_tokens, + timeout=timeout, + stop=None, + ) def get_context_length(self, model: str) -> int | None: return registry.lookup_context_length(REGISTRY_PATH, model) diff --git a/src/skillspector/providers/base.py b/src/skillspector/providers/base.py index 71452534..a18858e7 100644 --- a/src/skillspector/providers/base.py +++ b/src/skillspector/providers/base.py @@ -13,12 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Protocols for pluggable providers (model metadata + credentials).""" +"""Protocols for pluggable LLM providers.""" from __future__ import annotations from typing import Protocol +from langchain_core.language_models.chat_models import BaseChatModel + class ModelMetadataProvider(Protocol): """Provider-side knowledge about models — token budgets and defaults. @@ -47,3 +49,19 @@ class CredentialsProvider(Protocol): """ def resolve_credentials(self) -> tuple[str, str | None] | None: ... + + +class ChatModelProvider(Protocol): + """Anything that can construct its native LangChain chat model.""" + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: ... + + +class LLMProvider(ModelMetadataProvider, CredentialsProvider, ChatModelProvider, Protocol): + """Complete provider surface used by SkillSpector's LLM stack.""" diff --git a/src/skillspector/providers/chat_models.py b/src/skillspector/providers/chat_models.py new file mode 100644 index 00000000..3c0b2dfa --- /dev/null +++ b/src/skillspector/providers/chat_models.py @@ -0,0 +1,43 @@ +# 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. + +"""Shared constructors for provider-backed LangChain chat models.""" + +from __future__ import annotations + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_openai import ChatOpenAI +from pydantic import SecretStr + + +def create_openai_compatible_chat_model( + *, + model: str, + credentials: tuple[str, str | None] | None, + max_tokens: int, + timeout: float | None = 120, +) -> BaseChatModel | None: + """Create ``ChatOpenAI`` for providers serving OpenAI-compatible endpoints.""" + if credentials is None: + return None + + api_key, base_url = credentials + return ChatOpenAI( + model=model, + base_url=base_url, + api_key=SecretStr(api_key), + max_completion_tokens=max_tokens, + timeout=timeout, + ) diff --git a/src/skillspector/providers/nv_build/provider.py b/src/skillspector/providers/nv_build/provider.py index 0f0a3b51..f2a47cce 100644 --- a/src/skillspector/providers/nv_build/provider.py +++ b/src/skillspector/providers/nv_build/provider.py @@ -25,7 +25,10 @@ import os from pathlib import Path +from langchain_core.language_models.chat_models import BaseChatModel + from skillspector.providers import registry +from skillspector.providers.chat_models import create_openai_compatible_chat_model BUILD_BASE_URL = "https://integrate.api.nvidia.com/v1" @@ -50,6 +53,21 @@ def resolve_credentials(self) -> tuple[str, str | None] | None: return None return api_key, BUILD_BASE_URL + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatOpenAI`` for the build.nvidia.com endpoint.""" + return create_openai_compatible_chat_model( + model=model, + credentials=self.resolve_credentials(), + max_tokens=max_tokens, + timeout=timeout, + ) + def get_context_length(self, model: str) -> int | None: """Look up *model*'s context window in the bundled ``model_registry.yaml``.""" return registry.lookup_context_length(REGISTRY_PATH, model) diff --git a/src/skillspector/providers/openai/provider.py b/src/skillspector/providers/openai/provider.py index 3ae7e70a..ba199633 100644 --- a/src/skillspector/providers/openai/provider.py +++ b/src/skillspector/providers/openai/provider.py @@ -25,7 +25,10 @@ import os from pathlib import Path +from langchain_core.language_models.chat_models import BaseChatModel + from skillspector.providers import registry +from skillspector.providers.chat_models import create_openai_compatible_chat_model # Documented for completeness — ChatOpenAI defaults here when base_url=None. OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1" @@ -47,6 +50,21 @@ def resolve_credentials(self) -> tuple[str, str | None] | None: base_url = os.environ.get("OPENAI_BASE_URL", "").strip() or None return api_key, base_url + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatOpenAI`` using standard OpenAI environment variables.""" + return create_openai_compatible_chat_model( + model=model, + credentials=self.resolve_credentials(), + max_tokens=max_tokens, + timeout=timeout, + ) + def get_context_length(self, model: str) -> int | None: return registry.lookup_context_length(REGISTRY_PATH, model) diff --git a/tests/docker/smoke.sh b/tests/docker/smoke.sh new file mode 100755 index 00000000..512240ca --- /dev/null +++ b/tests/docker/smoke.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -eu + +IMAGE="${SKILLSPECTOR_DOCKER_IMAGE:-skillspector}" +REPO_DIR="${SKILLSPECTOR_REPO_DIR:-$(pwd)}" +LOCAL_REPORT="${SKILLSPECTOR_DOCKER_LOCAL_REPORT:-.skillspector-docker-smoke.json}" +GITHUB_REPORT="${SKILLSPECTOR_DOCKER_GITHUB_REPORT:-.skillspector-docker-github-smoke.json}" +GITHUB_URL="${SKILLSPECTOR_DOCKER_GITHUB_URL:-https://github.com/octocat/Hello-World}" +GITHUB_EXPECTED_COMPONENT="${SKILLSPECTOR_DOCKER_GITHUB_EXPECTED_COMPONENT:-README}" + +run() { + printf "\n>> %s\n" "$*" + "$@" + return +} + +validate_json_report() { + report_path="$1" + + test -s "${REPO_DIR}/${report_path}" + run docker run --rm --entrypoint python -v "${REPO_DIR}:/scan" "${IMAGE}" \ + -m json.tool "/scan/${report_path}" >/dev/null + return +} + +assert_report_contains_component() { + report_path="$1" + expected_component="$2" + + run docker run --rm --entrypoint python -v "${REPO_DIR}:/scan" "${IMAGE}" \ + -c 'import json, sys; data = json.load(open("/scan/" + sys.argv[1])); expected = sys.argv[2]; assert any(c.get("path") == expected for c in data.get("components", [])), f"missing component: {expected}"' \ + "${report_path}" "${expected_component}" + return +} + +scan_github_url() { + printf "\n>> docker run --rm -v %s:/scan %s scan %s --no-llm --format json --output /scan/%s\n" \ + "${REPO_DIR}" "${IMAGE}" "${GITHUB_URL}" "${GITHUB_REPORT}" + + set +e + docker run --rm -v "${REPO_DIR}:/scan" "${IMAGE}" scan "${GITHUB_URL}" \ + --no-llm --format json --output "/scan/${GITHUB_REPORT}" + github_scan_status="$?" + set -e + + if [ "${github_scan_status}" -ne 0 ] && [ "${github_scan_status}" -ne 1 ]; then + echo "GitHub URL scan failed with exit code ${github_scan_status}" + exit "${github_scan_status}" + fi + + validate_json_report "${GITHUB_REPORT}" + assert_report_contains_component "${GITHUB_REPORT}" "${GITHUB_EXPECTED_COMPONENT}" + echo "GitHub URL scan completed with accepted exit code ${github_scan_status}" + return +} + +run docker run --rm "${IMAGE}" --version +run docker run --rm --entrypoint git "${IMAGE}" --version + +run docker run --rm -v "${REPO_DIR}:/scan" "${IMAGE}" scan tests/fixtures/safe_skill \ + --no-llm --format json --output "/scan/${LOCAL_REPORT}" +validate_json_report "${LOCAL_REPORT}" + +scan_github_url diff --git a/tests/integration/test_graph.py b/tests/integration/test_graph.py index 80b0896d..031c7f9d 100644 --- a/tests/integration/test_graph.py +++ b/tests/integration/test_graph.py @@ -18,6 +18,8 @@ import json from pathlib import Path +import pytest + from skillspector.graph import graph @@ -28,6 +30,7 @@ def test_graph_invoke_with_output_format_json(tmp_path: Path) -> None: { "skill_path": str(tmp_path), "output_format": "json", + "use_llm": False, } ) body = result.get("report_body", "") @@ -39,9 +42,9 @@ def test_graph_invoke_with_output_format_json(tmp_path: Path) -> None: assert "components" in data -def test_graph_invoke_returns_findings_and_report() -> None: +def test_graph_invoke_returns_findings_and_report(tmp_path: Path) -> None: """Graph runs to completion; returns findings, SARIF report, report_body, risk_score.""" - result = graph.invoke({"skill_path": "/tmp/dummy-skill"}) + result = graph.invoke({"skill_path": str(tmp_path), "use_llm": False}) assert "findings" in result assert isinstance(result["findings"], list) @@ -50,3 +53,15 @@ def test_graph_invoke_returns_findings_and_report() -> None: assert "report_body" in result assert result["risk_score"] >= 0 assert isinstance(result["report_body"], str) + + +def test_graph_invalid_skill_path_raises() -> None: + """Invalid skill_path raises instead of returning a clean low-risk report.""" + with pytest.raises(ValueError, match="not an existing directory"): + graph.invoke( + { + "skill_path": "/nonexistent/path/xyz", + "output_format": "json", + "use_llm": False, + } + ) diff --git a/tests/nodes/test_build_context.py b/tests/nodes/test_build_context.py index a0fc16b9..26edee1a 100644 --- a/tests/nodes/test_build_context.py +++ b/tests/nodes/test_build_context.py @@ -22,6 +22,8 @@ from pathlib import Path +import pytest + from skillspector.constants import MODEL_CONFIG from skillspector.nodes.build_context import build_context from skillspector.state import SkillspectorState @@ -89,48 +91,43 @@ def test_build_context_real_directory_with_skill_md(tmp_path: Path) -> None: def test_build_context_missing_skill_path() -> None: - """Missing skill_path returns minimal state (including model_config, component_metadata).""" + """Missing skill_path raises instead of producing a clean empty scan.""" state: SkillspectorState = {} - result = build_context(state) - assert result == { - "components": [], - "file_cache": {}, - "ast_cache": {}, - "manifest": {}, - "previous_manifest": None, - "model_config": MODEL_CONFIG, - "component_metadata": [], - "has_executable_scripts": False, - } + with pytest.raises(ValueError, match="skill_path is required"): + build_context(state) def test_build_context_empty_skill_path() -> None: - """Empty skill_path returns minimal state.""" + """Empty skill_path raises instead of producing a clean empty scan.""" state: SkillspectorState = {"skill_path": ""} - result = build_context(state) - assert result["components"] == [] - assert result["file_cache"] == {} - assert result["manifest"] == {} + with pytest.raises(ValueError, match="skill_path is required"): + build_context(state) def test_build_context_nonexistent_path() -> None: - """Non-existent path returns minimal state.""" + """Non-existent path raises instead of producing a clean empty scan.""" state: SkillspectorState = {"skill_path": "/nonexistent/path/xyz"} - result = build_context(state) - assert result["components"] == [] - assert result["file_cache"] == {} - assert result["manifest"] == {} + with pytest.raises(ValueError, match="not an existing directory"): + build_context(state) def test_build_context_path_is_file_not_dir(tmp_path: Path) -> None: - """Path that is a file (not directory) returns minimal state.""" + """Path that is a file raises instead of producing a clean empty scan.""" f = tmp_path / "file.txt" f.write_text("x", encoding="utf-8") state: SkillspectorState = {"skill_path": str(f)} + with pytest.raises(ValueError, match="not an existing directory"): + build_context(state) + + +def test_build_context_empty_directory_is_valid_empty_scan(tmp_path: Path) -> None: + """An existing empty directory is a valid scan target with no components.""" + state: SkillspectorState = {"skill_path": str(tmp_path)} result = build_context(state) assert result["components"] == [] assert result["file_cache"] == {} assert result["manifest"] == {} + assert result["model_config"] == MODEL_CONFIG def test_build_context_skips_skip_dirs(tmp_path: Path) -> None: diff --git a/tests/nodes/test_llm_analyzer_base.py b/tests/nodes/test_llm_analyzer_base.py index 9899a7ff..c1fabca5 100644 --- a/tests/nodes/test_llm_analyzer_base.py +++ b/tests/nodes/test_llm_analyzer_base.py @@ -20,6 +20,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from langchain_core.messages import AIMessage from skillspector.llm_analyzer_base import ( Batch, @@ -164,6 +165,16 @@ def _mock_get_chat_model(*_args, **_kwargs): MOCK_PATCH_TARGET = "skillspector.llm_analyzer_base.get_chat_model" +class _RawTextAnalyzer(LLMAnalyzerBase): + """Test analyzer for raw-string mode.""" + + response_schema = None + + def parse_response(self, response: object, batch: Batch) -> list[str]: + assert isinstance(response, str) + return [response] + + # --------------------------------------------------------------------------- # number_lines # --------------------------------------------------------------------------- @@ -313,6 +324,35 @@ def test_empty_results(self) -> None: assert analyzer.collect_findings([]) == [] +# --------------------------------------------------------------------------- +# LLMAnalyzerBase raw-string mode +# --------------------------------------------------------------------------- + + +class TestRawStringMode: + MODEL = "nvidia/openai/gpt-oss-120b" + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + def test_run_batches_uses_message_text_for_content_blocks(self) -> None: + analyzer = _RawTextAnalyzer(base_prompt="test", model=self.MODEL) + analyzer._llm.invoke.return_value = AIMessage(content=[{"type": "text", "text": "chunk"}]) + + results = analyzer.run_batches([Batch(file_path="a.py", content="code")]) + + assert results[0][1] == ["chunk"] + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + async def test_arun_batches_uses_message_text_for_content_blocks(self) -> None: + analyzer = _RawTextAnalyzer(base_prompt="test", model=self.MODEL) + analyzer._llm.ainvoke = AsyncMock( + return_value=AIMessage(content=[{"type": "text", "text": "async chunk"}]) + ) + + results = await analyzer.arun_batches([Batch(file_path="a.py", content="code")]) + + assert results[0][1] == ["async chunk"] + + # --------------------------------------------------------------------------- # LLMAnalyzerBase.arun_batches (async parallel execution) # --------------------------------------------------------------------------- @@ -407,9 +447,7 @@ async def test_raw_string_mode(self) -> None: """When response_schema is None, arun_batches uses _llm.ainvoke.""" analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) analyzer._structured_llm = None - mock_response = MagicMock() - mock_response.content = "raw text" - analyzer._llm.ainvoke = AsyncMock(return_value=mock_response) + analyzer._llm.ainvoke = AsyncMock(return_value=AIMessage(content="raw text")) batch = Batch(file_path="a.py", content="code") with pytest.raises(NotImplementedError): diff --git a/tests/provider/__init__.py b/tests/provider/__init__.py new file mode 100644 index 00000000..52a7a9da --- /dev/null +++ b/tests/provider/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/provider/test_provider_endpoint.py b/tests/provider/test_provider_endpoint.py new file mode 100644 index 00000000..47d033bc --- /dev/null +++ b/tests/provider/test_provider_endpoint.py @@ -0,0 +1,107 @@ +# 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. + +"""Live OSS provider endpoint tests.""" + +from __future__ import annotations + +import os +import warnings + +import pytest +from langchain_core.messages import HumanMessage +from pydantic import BaseModel, Field + +pytestmark = [ + pytest.mark.provider, + pytest.mark.filterwarnings("ignore:Pydantic serializer warnings:UserWarning"), +] + + +class ProviderResult(BaseModel): + """Tiny schema used to validate provider structured-output wiring.""" + + ok: bool = Field(description="Whether the provider request succeeded.") + + +def _skip_without_env(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + message = f"{name} is not set; skipping this live provider test" + warnings.warn(message, RuntimeWarning, stacklevel=2) + pytest.skip(message) + return value + + +def _model_from_env(name: str, default: str) -> str: + return os.environ.get(name, "").strip() or default + + +def test_openai_provider_makes_live_structured_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OpenAI provider reaches its default endpoint and returns structured output.""" + from skillspector.providers.openai import OpenAIProvider + + _skip_without_env("OPENAI_API_KEY") + # This live provider check must hit OpenAI's default base URL, not a proxy. + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + model = _model_from_env("SKILLSPECTOR_OPENAI_TEST_MODEL", OpenAIProvider.DEFAULT_MODEL) + llm = OpenAIProvider().create_chat_model(model, max_tokens=32, timeout=60) + assert llm is not None + assert llm.openai_api_base is None + + result = llm.with_structured_output(ProviderResult).invoke( + [HumanMessage(content="Return only the requested structured output with ok=true.")] + ) + + assert result == ProviderResult(ok=True) + + +def test_anthropic_provider_makes_live_structured_request() -> 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") + + model = _model_from_env("SKILLSPECTOR_ANTHROPIC_TEST_MODEL", AnthropicProvider.DEFAULT_MODEL) + llm = AnthropicProvider().create_chat_model(model, max_tokens=32, timeout=60) + assert llm is not None + assert str(llm.anthropic_api_url).rstrip("/") == ANTHROPIC_BASE_URL.rstrip("/") + + result = llm.with_structured_output(ProviderResult).invoke( + [HumanMessage(content="Return only the requested structured output with ok=true.")] + ) + + assert result == ProviderResult(ok=True) + + +def test_nv_build_provider_makes_live_structured_request() -> None: + """NVIDIA Build provider reaches its default endpoint and returns structured output.""" + from skillspector.providers.nv_build import BUILD_BASE_URL, NvBuildProvider + + _skip_without_env("NVIDIA_INFERENCE_KEY") + + model = _model_from_env("SKILLSPECTOR_NV_BUILD_TEST_MODEL", NvBuildProvider.DEFAULT_MODEL) + llm = NvBuildProvider().create_chat_model(model, max_tokens=32, timeout=60) + assert llm is not None + assert str(llm.openai_api_base).rstrip("/") == BUILD_BASE_URL.rstrip("/") + + result = llm.with_structured_output(ProviderResult).invoke( + [HumanMessage(content="Return only the requested structured output with ok=true.")] + ) + + assert result == ProviderResult(ok=True) diff --git a/tests/unit/test_llm_utils.py b/tests/unit/test_llm_utils.py index 97a46c13..5e89eadf 100644 --- a/tests/unit/test_llm_utils.py +++ b/tests/unit/test_llm_utils.py @@ -15,22 +15,33 @@ """Tests for the LLM credential resolution in llm_utils. -Order: active NVIDIA provider (NVIDIA_INFERENCE_KEY) -> OPENAI_API_KEY / -OPENAI_BASE_URL. NVIDIA-specific behavior (which env var resolves to -which endpoint) lives in the active provider — see ``tests/unit/test_providers.py``. +Order: active SkillSpector provider -> OPENAI_API_KEY / OPENAI_BASE_URL. +Provider-specific behavior (which env var resolves to which client) lives +in the active provider — see ``tests/unit/test_providers.py``. """ from __future__ import annotations import pytest - -from skillspector.llm_utils import _resolve_llm_credentials, is_llm_available -from skillspector.providers import resolve_provider_credentials +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import AIMessage + +from skillspector import llm_utils +from skillspector.llm_utils import ( + _resolve_llm_credentials, + chat_completion, + fetch_model_token_limits, + get_chat_model, + is_llm_available, +) +from skillspector.providers import NO_LLM_API_KEY_MESSAGE, resolve_provider_credentials _LLM_ENV_VARS = ( + "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", "NVIDIA_INFERENCE_KEY", + "SKILLSPECTOR_PROVIDER", ) @@ -43,7 +54,7 @@ def _clean_llm_env(monkeypatch: pytest.MonkeyPatch): class TestCredentialResolution: - """Order: active NVIDIA provider first, then OPENAI_API_KEY / OPENAI_BASE_URL.""" + """Order: active provider first, then OPENAI_API_KEY / OPENAI_BASE_URL.""" def test_provider_wins_when_configured(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NVIDIA_INFERENCE_KEY", "nvidia-key") @@ -77,9 +88,77 @@ def test_provider_base_url_not_overridden_by_openai_base_url( _, base = _resolve_llm_credentials() assert base == provider_creds[1] + def test_anthropic_provider_wins_with_native_credentials( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + key, base = _resolve_llm_credentials() + assert key == "sk-ant-x" + assert base is None + def test_no_credentials_raises_with_helpful_message(self) -> None: - with pytest.raises(ValueError, match="API key"): + with pytest.raises(ValueError) as exc_info: _resolve_llm_credentials() + assert str(exc_info.value) == NO_LLM_API_KEY_MESSAGE + + def test_get_chat_model_returns_native_anthropic_client( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") + llm = get_chat_model(model="claude-opus-4-6") + assert isinstance(llm, ChatAnthropic) + assert llm.model == "claude-opus-4-6" + + +class TestFetchModelTokenLimits: + def test_returns_input_and_output_token_pair(self) -> None: + max_input, max_output = fetch_model_token_limits("claude-opus-4-6") + assert isinstance(max_input, int) + assert isinstance(max_output, int) + assert max_input > 0 + assert max_output > 0 + + +class TestChatCompletion: + """``chat_completion`` invokes the active chat model and normalizes content.""" + + def test_returns_string_content_directly(self, monkeypatch: pytest.MonkeyPatch) -> None: + class _FakeLLM: + def invoke(self, prompt: str) -> AIMessage: + assert prompt == "ping" + return AIMessage(content="hello world") + + monkeypatch.setattr(llm_utils, "get_chat_model", lambda model=None: _FakeLLM()) + assert chat_completion("ping") == "hello world" + + def test_returns_text_from_langchain_content_blocks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + class _FakeLLM: + def invoke(self, prompt: str) -> AIMessage: + return AIMessage(content=[{"type": "text", "text": "chunk"}]) + + captured: dict[str, str | None] = {} + + def _fake_get_chat_model(model: str | None = None) -> _FakeLLM: + captured["model"] = model + return _FakeLLM() + + monkeypatch.setattr(llm_utils, "get_chat_model", _fake_get_chat_model) + result = chat_completion("prompt", model="some-model") + assert result == "chunk" + assert captured["model"] == "some-model" + + def test_returns_empty_text(self, monkeypatch: pytest.MonkeyPatch) -> None: + class _FakeLLM: + def invoke(self, prompt: str) -> AIMessage: + return AIMessage(content="") + + monkeypatch.setattr(llm_utils, "get_chat_model", lambda model=None: _FakeLLM()) + assert chat_completion("prompt") == "" class TestIsLlmAvailable: @@ -98,5 +177,4 @@ def test_returns_true_via_provider(self, monkeypatch: pytest.MonkeyPatch) -> Non def test_returns_false_with_message_when_no_credentials(self) -> None: ok, msg = is_llm_available() assert ok is False - assert msg is not None - assert "API key" in msg + assert msg == NO_LLM_API_KEY_MESSAGE diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index ce736445..acfdaf71 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -22,14 +22,22 @@ from __future__ import annotations +import sys + import pytest +from langchain_anthropic import ChatAnthropic +from langchain_openai import ChatOpenAI from skillspector.providers import ( + NO_LLM_API_KEY_MESSAGE, + create_chat_model, get_metadata_provider, registry, + resolve_chat_model_credentials, resolve_provider_credentials, ) -from skillspector.providers.anthropic import ANTHROPIC_BASE_URL, AnthropicProvider +from skillspector.providers.anthropic import AnthropicProvider +from skillspector.providers.chat_models import create_openai_compatible_chat_model from skillspector.providers.nv_build import BUILD_BASE_URL, NvBuildProvider from skillspector.providers.openai import OpenAIProvider @@ -76,6 +84,17 @@ def test_resolves_to_build_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> No creds = NvBuildProvider().resolve_credentials() assert creds == ("nvapi-x", BUILD_BASE_URL) + def test_creates_openai_compatible_chat_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NVIDIA_INFERENCE_KEY", "nvapi-x") + llm = NvBuildProvider().create_chat_model( + "deepseek-ai/deepseek-v4-flash", + max_tokens=123, + ) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "deepseek-ai/deepseek-v4-flash" + assert llm.max_tokens == 123 + assert str(llm.openai_api_base).rstrip("/") == BUILD_BASE_URL.rstrip("/") + def test_metadata_known_model_from_bundled_yaml(self) -> None: """deepseek-v4-flash ships in nv_build/model_registry.yaml.""" provider = NvBuildProvider() @@ -123,6 +142,17 @@ def test_resolves_to_inference_endpoint(self, monkeypatch: pytest.MonkeyPatch) - creds = NvInferenceProvider().resolve_credentials() assert creds == ("internal-key", INFERENCE_BASE_URL) + def test_creates_openai_compatible_chat_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NVIDIA_INFERENCE_KEY", "internal-key") + llm = NvInferenceProvider().create_chat_model( + "azure/anthropic/claude-sonnet-4-6", + max_tokens=123, + ) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "azure/anthropic/claude-sonnet-4-6" + assert llm.max_tokens == 123 + assert str(llm.openai_api_base).rstrip("/") == INFERENCE_BASE_URL.rstrip("/") + def test_metadata_key_not_required_for_credentials( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -179,6 +209,13 @@ def test_honors_openai_base_url_override(self, monkeypatch: pytest.MonkeyPatch) creds = OpenAIProvider().resolve_credentials() assert creds == ("sk-x", "http://localhost:11434/v1") + def test_creates_chat_openai(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + llm = OpenAIProvider().create_chat_model("gpt-5.4", max_tokens=123) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "gpt-5.4" + assert llm.max_tokens == 123 + def test_default_model(self) -> None: assert OpenAIProvider().resolve_model() == "gpt-5.4" # All slots inherit DEFAULT_MODEL — gpt-5.4 everywhere. @@ -196,10 +233,23 @@ class TestAnthropicProvider: def test_returns_none_without_env_var(self) -> None: assert AnthropicProvider().resolve_credentials() is None - def test_resolves_to_anthropic_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_resolves_anthropic_api_key_without_openai_endpoint( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") creds = AnthropicProvider().resolve_credentials() - assert creds == ("sk-ant-x", ANTHROPIC_BASE_URL) + assert creds == ("sk-ant-x", None) + + 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 + + def test_create_chat_model_returns_none_without_key(self) -> None: + # No ANTHROPIC_API_KEY → no client, signalling the caller to fall back. + assert AnthropicProvider().create_chat_model("claude-opus-4-6", max_tokens=123) is None def test_default_model_and_meta_downgrade(self) -> None: assert AnthropicProvider().resolve_model() == "claude-opus-4-6" @@ -212,6 +262,31 @@ def test_metadata_known_models(self) -> None: assert provider.get_context_length("claude-sonnet-4-6") == 1_000_000 +class TestOpenAICompatibleConstructor: + """The shared OpenAI-compatible chat-model constructor.""" + + def test_returns_none_when_credentials_missing(self) -> None: + assert ( + create_openai_compatible_chat_model( + model="gpt-5.4", + credentials=None, + max_tokens=123, + ) + is None + ) + + def test_builds_chat_openai_from_credentials(self) -> None: + llm = create_openai_compatible_chat_model( + model="gpt-5.4", + credentials=("sk-x", "http://localhost:1234/v1"), + max_tokens=123, + ) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "gpt-5.4" + assert llm.max_tokens == 123 + assert str(llm.openai_api_base).rstrip("/") == "http://localhost:1234/v1" + + class TestProviderSelection: """SKILLSPECTOR_PROVIDER selects which provider answers credentials.""" @@ -243,9 +318,26 @@ def test_select_anthropic(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic") monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") creds = resolve_provider_credentials() - assert creds == ("sk-ant-x", ANTHROPIC_BASE_URL) + assert creds == ("sk-ant-x", None) assert isinstance(get_metadata_provider(), AnthropicProvider) + def test_create_chat_model_uses_native_anthropic_when_configured( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") + monkeypatch.setenv("OPENAI_API_KEY", "openai-should-not-win") + llm = create_chat_model("claude-opus-4-6", max_tokens=123) + assert isinstance(llm, ChatAnthropic) + assert llm.model == "claude-opus-4-6" + + def test_chat_model_credentials_fall_back_to_openai( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + creds = resolve_chat_model_credentials() + assert creds == ("sk-x", None) + def test_select_nv_build(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "nv_build") monkeypatch.setenv("NVIDIA_INFERENCE_KEY", "nvapi-x") @@ -257,3 +349,43 @@ def test_unknown_provider_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "vertex") with pytest.raises(ValueError, match="Unknown SKILLSPECTOR_PROVIDER"): get_metadata_provider() + + def test_falls_back_to_nv_build_when_nv_inference_unimportable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When the optional nv_inference subpackage can't be imported, + the default/``nv_inference`` selection degrades to ``NvBuildProvider``.""" + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "nv_inference") + # Setting the module entry to None forces ``import`` to raise ImportError. + monkeypatch.setitem(sys.modules, "skillspector.providers.nv_inference", None) + assert isinstance(get_metadata_provider(), NvBuildProvider) + + def test_create_chat_model_falls_back_to_openai_when_provider_unconfigured( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Active provider is anthropic but ANTHROPIC_API_KEY is unset, so it + # yields no client; OPENAI_API_KEY then satisfies the fallback. + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic") + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + llm = create_chat_model("gpt-5.4", max_tokens=123) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "gpt-5.4" + + def test_create_chat_model_raises_when_no_credentials_anywhere( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Anthropic active, but neither ANTHROPIC_API_KEY nor OPENAI_API_KEY set. + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "anthropic") + with pytest.raises(ValueError) as exc_info: + create_chat_model("claude-opus-4-6", max_tokens=123) + assert str(exc_info.value) == NO_LLM_API_KEY_MESSAGE + + def test_create_chat_model_raises_for_openai_provider_without_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # When the active provider is already OpenAI, there is no second + # fallback attempt — it raises directly. + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "openai") + with pytest.raises(ValueError) as exc_info: + create_chat_model("gpt-5.4", max_tokens=123) + assert str(exc_info.value) == NO_LLM_API_KEY_MESSAGE diff --git a/uv.lock b/uv.lock index ea15e932..8d69abc4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 2 -requires-python = ">=3.12" +requires-python = ">=3.12, <3.15" [[package]] name = "annotated-doc" @@ -20,6 +20,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.109.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/0b/ce24a4f275573f5e436ca954faca60c759d58ed152b8fa36a1e3b888e261/anthropic-0.109.1.tar.gz", hash = "sha256:83e06b3d9d40ff5898f588020e0cc4e42187de954549a3b5fbe6e2685a09c785", size = 927569, upload-time = "2026-06-09T23:55:24.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/0f/a6110d713370bc92f074a622f8a5ebdec7e92360149b1048dca258a07b2f/anthropic-0.109.1-py3-none-any.whl", hash = "sha256:ce7d94a7657f2aa29338cca448945eac621b4f62c1794cf461cb32847223e9b8", size = 923851, upload-time = "2026-06-09T23:55:23.348Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -402,6 +421,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "docutils" version = "0.22.4" @@ -851,12 +879,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "langchain-anthropic" +version = "1.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/f4/404a6a4b75133479ac0426f4080c7f75cc02f4220df5282188b2c1913abe/langchain_anthropic-1.4.5.tar.gz", hash = "sha256:ed5cb345cee846a1c69a11e1e55cfd2deedac31a8ea3bea46568ea1702d2e702", size = 693439, upload-time = "2026-06-10T21:41:12.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/74/365775548cedaff9a93420f9436c665114dd9f399225ac7200b4958407e0/langchain_anthropic-1.4.5-py3-none-any.whl", hash = "sha256:6f6811a7f7fb815061639728ad58ba7d7de9cc1b23dd076f4690cfdc2f733969", size = 51343, upload-time = "2026-06-10T21:41:11.026Z" }, +] + [[package]] name = "langchain-core" -version = "1.2.17" +version = "1.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, + { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, { name = "pydantic" }, @@ -865,9 +908,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/93/36226f593df52b871fc24d494c274f3a6b2ac76763a2806e7d35611634a1/langchain_core-1.2.17.tar.gz", hash = "sha256:54aa267f3311e347fb2e50951fe08e53761cebfb999ab80e6748d70525bbe872", size = 836130, upload-time = "2026-03-02T22:47:55.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/46/3e5247fd68fcb68d7ad4f93133e1507ea47b918cdd5da26cc75513e4530c/langchain_core-1.4.5.tar.gz", hash = "sha256:1354ffa79988f0d90211236abf8d1403d3d8fc199774b82a0e4b7e400881cb38", size = 938856, upload-time = "2026-06-11T02:57:51.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/90/073f33ab383a62908eca7ea699586dfea280e77182176e33199c80ddf22a/langchain_core-1.2.17-py3-none-any.whl", hash = "sha256:bf6bd6ce503874e9c2da1669a69383e967c3de1ea808921d19a9a6bff1a9fbbe", size = 502727, upload-time = "2026-03-02T22:47:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/10/44/8ada10e7af0002da860dcdfb26fae76de857e3a70a08b07f657cf2f2547e/langchain_core-1.4.5-py3-none-any.whl", hash = "sha256:c5a54efb4176d3c7550d331ed2cdf64355e880f94f564e0d06c8bcd9d0dda763", size = 552205, upload-time = "2026-06-11T02:57:50.384Z" }, ] [[package]] @@ -884,6 +927,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/17/3785cbcdc81c451179247e4176d2697879cb4f45ab2c59d949ca574e072d/langchain_openai-1.1.10-py3-none-any.whl", hash = "sha256:d91b2c09e9fbc70f7af45345d3aa477744962d41c73a029beb46b4f83b824827", size = 87205, upload-time = "2026-02-17T18:03:43.502Z" }, ] +[[package]] +name = "langchain-protocol" +version = "0.0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/e7/8300ba22d968653051fd06e3117d783872dddf3dcebdd6b1d386836eb43c/langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff", size = 5969, upload-time = "2026-05-28T23:05:11.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/9c/06dfcc88d02a6364e8d864c421ddd3736305cb0a6c853f75c302c80fe17c/langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3", size = 7037, upload-time = "2026-05-28T23:05:10.163Z" }, +] + [[package]] name = "langgraph" version = "1.0.10" @@ -2095,10 +2150,11 @@ wheels = [ [[package]] name = "skillspector" -version = "2.1.2" +version = "2.2.3" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "langchain-anthropic" }, { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -2128,6 +2184,7 @@ dev = [ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.4.0" }, { name = "httpx", specifier = ">=0.28.0" }, + { name = "langchain-anthropic", specifier = ">=1.4.5" }, { name = "langchain-core", specifier = ">=1.2.17" }, { name = "langchain-openai", specifier = ">=1.1.10" }, { name = "langgraph", specifier = ">=1.0.10" },