diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index 307ae6a..d826588 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -22,10 +22,13 @@ Selection happens via the ``SKILLSPECTOR_PROVIDER`` env var: - openai → OpenAIProvider (api.openai.com) - anthropic → AnthropicProvider (api.anthropic.com) - anthropic_proxy → AnthropicProxyProvider (Vertex-style raw-predict proxy) - 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) + ollama → OllamaProvider (local Ollama instance) + azure_openai → AzureOpenAIProvider (Azure OpenAI Service) + openai_compatible → OpenAICompatibleProvider (Groq, Together AI, Mistral, etc.) When unset, the selector defaults to ``nv_build``. """ @@ -44,6 +47,7 @@ "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. " + "For local models, try SKILLSPECTOR_PROVIDER=ollama. " "Use --no-llm to skip LLM analysis and run static checks only." ) @@ -69,6 +73,18 @@ def _select_active_provider() -> LLMProvider: from .anthropic_proxy import AnthropicProxyProvider return AnthropicProxyProvider() + if name == "ollama": + from .ollama import OllamaProvider + + return OllamaProvider() + if name == "azure_openai": + from .azure_openai import AzureOpenAIProvider + + return AzureOpenAIProvider() + if name == "openai_compatible": + from .openai_compatible import OpenAICompatibleProvider + + return OpenAICompatibleProvider() if name == "nv_build": return NvBuildProvider() if name in ("nv_inference", ""): @@ -83,7 +99,8 @@ def _select_active_provider() -> LLMProvider: raise ValueError( f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. " - "Expected one of: openai, anthropic, anthropic_proxy, nv_build (or unset)." + "Expected one of: openai, anthropic, anthropic_proxy, ollama, " + "azure_openai, openai_compatible, nv_build (or unset)." ) diff --git a/src/skillspector/providers/azure_openai/__init__.py b/src/skillspector/providers/azure_openai/__init__.py new file mode 100644 index 0000000..02c9b92 --- /dev/null +++ b/src/skillspector/providers/azure_openai/__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. + +"""Azure OpenAI provider package (Azure-hosted OpenAI deployments).""" + +from .provider import REGISTRY_PATH, AzureOpenAIProvider + +__all__ = ["REGISTRY_PATH", "AzureOpenAIProvider"] diff --git a/src/skillspector/providers/azure_openai/model_registry.yaml b/src/skillspector/providers/azure_openai/model_registry.yaml new file mode 100644 index 0000000..0b9ae77 --- /dev/null +++ b/src/skillspector/providers/azure_openai/model_registry.yaml @@ -0,0 +1,23 @@ +# Token-budget metadata for the AzureOpenAIProvider (Azure-hosted OpenAI +# deployments). Bundled with the package; consulted whenever the active +# provider is AzureOpenAIProvider. +# +# Format: +# models: +# "": +# context_length: # total context window in tokens (required) +# max_output_tokens: # model's max output cap (optional) + +models: + "gpt-4o": + context_length: 128000 + max_output_tokens: 16384 + "gpt-4o-mini": + context_length: 128000 + max_output_tokens: 16384 + "gpt-4": + context_length: 8192 + max_output_tokens: 4096 + "gpt-4-turbo": + context_length: 128000 + max_output_tokens: 4096 diff --git a/src/skillspector/providers/azure_openai/provider.py b/src/skillspector/providers/azure_openai/provider.py new file mode 100644 index 0000000..253549d --- /dev/null +++ b/src/skillspector/providers/azure_openai/provider.py @@ -0,0 +1,92 @@ +# 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. + +"""Azure OpenAI provider — enterprise Azure-hosted OpenAI deployments. + +Uses ``AzureChatOpenAI`` from ``langchain_openai`` which handles Azure's +deployment-based routing and ``api-version`` query parameter natively. + +Required env vars: + AZURE_OPENAI_ENDPOINT — Azure resource endpoint + AZURE_OPENAI_API_KEY — Azure API key + +Optional env vars: + AZURE_OPENAI_DEPLOYMENT — deployment name (defaults to model label) + AZURE_OPENAI_API_VERSION — API version (defaults to ``2024-06-01``) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_openai import AzureChatOpenAI +from pydantic import SecretStr + +from skillspector.providers import registry + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + + +class AzureOpenAIProvider: + """Azure OpenAI credentials + bundled-YAML metadata provider.""" + + DEFAULT_MODEL = "gpt-4o" + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Return ``(api_key, endpoint)`` from Azure OpenAI env vars.""" + api_key = os.environ.get("AZURE_OPENAI_API_KEY", "").strip() + endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip() + if not api_key or not endpoint: + return None + return api_key, endpoint + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``AzureChatOpenAI`` using Azure-specific credentials.""" + creds = self.resolve_credentials() + if creds is None: + return None + + api_key, endpoint = creds + deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT", "").strip() or model + api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "").strip() or "2024-06-01" + + return AzureChatOpenAI( + azure_endpoint=endpoint, + azure_deployment=deployment, + api_key=SecretStr(api_key), + api_version=api_version, + max_tokens=max_tokens, + timeout=timeout, + ) + + 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/src/skillspector/providers/ollama/__init__.py b/src/skillspector/providers/ollama/__init__.py new file mode 100644 index 0000000..c3aa978 --- /dev/null +++ b/src/skillspector/providers/ollama/__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. + +"""Ollama provider package (local/self-hosted OpenAI-compatible endpoint).""" + +from .provider import OLLAMA_DEFAULT_BASE_URL, REGISTRY_PATH, OllamaProvider + +__all__ = ["OLLAMA_DEFAULT_BASE_URL", "REGISTRY_PATH", "OllamaProvider"] diff --git a/src/skillspector/providers/ollama/model_registry.yaml b/src/skillspector/providers/ollama/model_registry.yaml new file mode 100644 index 0000000..90e8693 --- /dev/null +++ b/src/skillspector/providers/ollama/model_registry.yaml @@ -0,0 +1,29 @@ +# Token-budget metadata for the OllamaProvider (local/self-hosted LLMs). +# Bundled with the package; consulted whenever the active provider is +# OllamaProvider. +# +# Format: +# models: +# "": +# context_length: # total context window in tokens (required) +# max_output_tokens: # model's max output cap (optional) + +models: + "llama3.1:8b": + context_length: 131072 + max_output_tokens: 4096 + "llama3.1:70b": + context_length: 131072 + max_output_tokens: 4096 + "mistral:7b": + context_length: 32768 + max_output_tokens: 4096 + "qwen2.5:7b": + context_length: 131072 + max_output_tokens: 8192 + "gemma2:9b": + context_length: 8192 + max_output_tokens: 4096 + "deepseek-v3:latest": + context_length: 65536 + max_output_tokens: 8192 diff --git a/src/skillspector/providers/ollama/provider.py b/src/skillspector/providers/ollama/provider.py new file mode 100644 index 0000000..d2b4236 --- /dev/null +++ b/src/skillspector/providers/ollama/provider.py @@ -0,0 +1,80 @@ +# 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. + +"""Ollama provider — local/self-hosted LLM via OpenAI-compatible API. + +Ollama serves an OpenAI-compatible endpoint at ``http://localhost:11434/v1`` +by default. No API key is required; ``ChatOpenAI`` needs a non-empty +placeholder string. + +Override the endpoint via ``OLLAMA_BASE_URL`` for remote Ollama instances. +""" + +from __future__ import annotations + +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 + +OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1" + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + + +class OllamaProvider: + """Ollama credentials + bundled-YAML metadata provider.""" + + DEFAULT_MODEL = "llama3.1:8b" + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Return ``(api_key, base_url)`` for the Ollama endpoint. + + Ollama does not require an API key but ``ChatOpenAI`` needs a + non-empty string — we supply the literal ``"ollama"``. + Always returns credentials (Ollama is assumed available when selected). + """ + base_url = os.environ.get("OLLAMA_BASE_URL", "").strip() or OLLAMA_DEFAULT_BASE_URL + return "ollama", base_url + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatOpenAI`` pointing at the Ollama 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: + 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/src/skillspector/providers/openai_compatible/__init__.py b/src/skillspector/providers/openai_compatible/__init__.py new file mode 100644 index 0000000..7571e2e --- /dev/null +++ b/src/skillspector/providers/openai_compatible/__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. + +"""Generic OpenAI-compatible provider package (Groq, Together AI, Mistral, etc.).""" + +from .provider import REGISTRY_PATH, OpenAICompatibleProvider + +__all__ = ["REGISTRY_PATH", "OpenAICompatibleProvider"] diff --git a/src/skillspector/providers/openai_compatible/model_registry.yaml b/src/skillspector/providers/openai_compatible/model_registry.yaml new file mode 100644 index 0000000..81f34d7 --- /dev/null +++ b/src/skillspector/providers/openai_compatible/model_registry.yaml @@ -0,0 +1,43 @@ +# Token-budget metadata for the OpenAICompatibleProvider (Groq, Together AI, +# Mistral, DeepInfra, Fireworks, LiteLLM, and other OpenAI-compatible +# endpoints). Bundled with the package; consulted whenever the active +# provider is OpenAICompatibleProvider. +# +# Format: +# models: +# "": +# context_length: # total context window in tokens (required) +# max_output_tokens: # model's max output cap (optional) + +models: + # Groq + "llama-3.1-70b-versatile": + context_length: 131072 + max_output_tokens: 8192 + "llama-3.1-8b-instant": + context_length: 131072 + max_output_tokens: 8192 + "mixtral-8x7b-32768": + context_length: 32768 + max_output_tokens: 4096 + + # Together AI + "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": + context_length: 131072 + max_output_tokens: 4096 + "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": + context_length: 131072 + max_output_tokens: 4096 + + # Mistral + "mistral-large-latest": + context_length: 131072 + max_output_tokens: 8192 + "mistral-small-latest": + context_length: 131072 + max_output_tokens: 8192 + + # DeepInfra / generic + "deepseek-ai/DeepSeek-V3": + context_length: 65536 + max_output_tokens: 8192 diff --git a/src/skillspector/providers/openai_compatible/provider.py b/src/skillspector/providers/openai_compatible/provider.py new file mode 100644 index 0000000..6a5923c --- /dev/null +++ b/src/skillspector/providers/openai_compatible/provider.py @@ -0,0 +1,78 @@ +# 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. + +"""Generic OpenAI-compatible provider — Groq, Together AI, Mistral, etc. + +Serves any endpoint that speaks the OpenAI chat completion protocol. +Uses dedicated ``SKILLSPECTOR_COMPAT_*`` env vars so credentials are not +confused with stock OpenAI settings. + +Required env vars: + SKILLSPECTOR_COMPAT_API_KEY — API key for the target provider + SKILLSPECTOR_COMPAT_BASE_URL — Base URL (e.g. https://api.groq.com/openai/v1) +""" + +from __future__ import annotations + +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 + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + + +class OpenAICompatibleProvider: + """Generic OpenAI-compatible credentials + bundled-YAML metadata provider.""" + + DEFAULT_MODEL = "llama-3.1-70b-versatile" + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Return ``(api_key, base_url)`` from ``SKILLSPECTOR_COMPAT_*`` env vars.""" + api_key = os.environ.get("SKILLSPECTOR_COMPAT_API_KEY", "").strip() + base_url = os.environ.get("SKILLSPECTOR_COMPAT_BASE_URL", "").strip() + if not api_key or not base_url: + return None + return api_key, base_url + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create ``ChatOpenAI`` for the configured compatible 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: + 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_new_providers.py b/tests/unit/test_new_providers.py new file mode 100644 index 0000000..620430d --- /dev/null +++ b/tests/unit/test_new_providers.py @@ -0,0 +1,280 @@ +# 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 Ollama, Azure OpenAI, and generic OpenAI-compatible providers.""" + +from __future__ import annotations + +import pytest +from langchain_openai import AzureChatOpenAI, ChatOpenAI + +from skillspector.providers import ( + create_chat_model, + get_metadata_provider, + registry, + resolve_provider_credentials, +) +from skillspector.providers.azure_openai import AzureOpenAIProvider +from skillspector.providers.ollama import OLLAMA_DEFAULT_BASE_URL, OllamaProvider +from skillspector.providers.openai_compatible import OpenAICompatibleProvider + + +@pytest.fixture(autouse=True) +def _clean_provider_env(monkeypatch: pytest.MonkeyPatch): + """Isolate provider-related env vars and the YAML cache for each test.""" + for key in ( + "NVIDIA_INFERENCE_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "SKILLSPECTOR_MODEL", + "SKILLSPECTOR_MODEL_REGISTRY", + "SKILLSPECTOR_PROVIDER", + "OLLAMA_BASE_URL", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_DEPLOYMENT", + "AZURE_OPENAI_API_VERSION", + "SKILLSPECTOR_COMPAT_API_KEY", + "SKILLSPECTOR_COMPAT_BASE_URL", + ): + monkeypatch.delenv(key, raising=False) + registry._load.cache_clear() + yield + registry._load.cache_clear() + + +# ── Ollama ────────────────────────────────────────────────────────────────── + + +class TestOllamaProvider: + """Ollama provider — local/self-hosted LLM endpoint.""" + + def test_always_returns_credentials(self) -> None: + creds = OllamaProvider().resolve_credentials() + assert creds is not None + api_key, base_url = creds + assert api_key == "ollama" + assert base_url == OLLAMA_DEFAULT_BASE_URL + + def test_custom_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OLLAMA_BASE_URL", "http://gpu-server:11434/v1") + creds = OllamaProvider().resolve_credentials() + assert creds == ("ollama", "http://gpu-server:11434/v1") + + def test_creates_chat_openai(self) -> None: + llm = OllamaProvider().create_chat_model("llama3.1:8b", max_tokens=512) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "llama3.1:8b" + assert llm.max_tokens == 512 + assert str(llm.openai_api_base).rstrip("/") == OLLAMA_DEFAULT_BASE_URL.rstrip("/") + + def test_default_model(self) -> None: + assert OllamaProvider().resolve_model() == "llama3.1:8b" + + def test_metadata_known_model(self) -> None: + provider = OllamaProvider() + assert provider.get_context_length("llama3.1:8b") == 131072 + assert provider.get_max_output_tokens("llama3.1:8b") == 4096 + + def test_metadata_unknown_model_returns_none(self) -> None: + provider = OllamaProvider() + assert provider.get_context_length("unknown-model") is None + + def test_env_model_overrides_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_MODEL", "mistral:7b") + assert OllamaProvider().resolve_model() == "mistral:7b" + + +class TestOllamaProviderSelection: + """SKILLSPECTOR_PROVIDER=ollama selects the Ollama provider.""" + + def test_select_ollama(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "ollama") + assert isinstance(get_metadata_provider(), OllamaProvider) + + def test_ollama_credentials_via_selector(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "ollama") + creds = resolve_provider_credentials() + assert creds is not None + assert creds[0] == "ollama" + + def test_create_chat_model_with_ollama(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "ollama") + llm = create_chat_model("llama3.1:8b", max_tokens=512) + assert isinstance(llm, ChatOpenAI) + + +# ── Azure OpenAI ──────────────────────────────────────────────────────────── + + +class TestAzureOpenAIProvider: + """Azure OpenAI provider — enterprise Azure deployments.""" + + def test_returns_none_without_env_vars(self) -> None: + assert AzureOpenAIProvider().resolve_credentials() is None + + def test_returns_none_with_key_only(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + assert AzureOpenAIProvider().resolve_credentials() is None + + def test_returns_none_with_endpoint_only(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + assert AzureOpenAIProvider().resolve_credentials() is None + + def test_resolves_with_both_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + creds = AzureOpenAIProvider().resolve_credentials() + assert creds == ("azure-key", "https://myorg.openai.azure.com/") + + def test_creates_azure_chat_openai(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + monkeypatch.setenv("AZURE_OPENAI_DEPLOYMENT", "my-gpt4o") + llm = AzureOpenAIProvider().create_chat_model("gpt-4o", max_tokens=1024) + assert isinstance(llm, AzureChatOpenAI) + assert llm.deployment_name == "my-gpt4o" + + def test_deployment_defaults_to_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + llm = AzureOpenAIProvider().create_chat_model("gpt-4o", max_tokens=1024) + assert isinstance(llm, AzureChatOpenAI) + assert llm.deployment_name == "gpt-4o" + + def test_api_version_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + llm = AzureOpenAIProvider().create_chat_model("gpt-4o", max_tokens=1024) + assert isinstance(llm, AzureChatOpenAI) + assert llm.openai_api_version == "2024-06-01" + + def test_custom_api_version(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2025-01-01") + llm = AzureOpenAIProvider().create_chat_model("gpt-4o", max_tokens=1024) + assert isinstance(llm, AzureChatOpenAI) + assert llm.openai_api_version == "2025-01-01" + + def test_default_model(self) -> None: + assert AzureOpenAIProvider().resolve_model() == "gpt-4o" + + def test_metadata_known_model(self) -> None: + provider = AzureOpenAIProvider() + assert provider.get_context_length("gpt-4o") == 128000 + assert provider.get_max_output_tokens("gpt-4o") == 16384 + + def test_create_returns_none_without_credentials(self) -> None: + assert AzureOpenAIProvider().create_chat_model("gpt-4o", max_tokens=1024) is None + + +class TestAzureOpenAIProviderSelection: + """SKILLSPECTOR_PROVIDER=azure_openai selects the Azure provider.""" + + def test_select_azure_openai(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "azure_openai") + assert isinstance(get_metadata_provider(), AzureOpenAIProvider) + + def test_azure_credentials_via_selector(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "azure_openai") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://myorg.openai.azure.com/") + creds = resolve_provider_credentials() + assert creds == ("azure-key", "https://myorg.openai.azure.com/") + + +# ── Generic OpenAI-Compatible ─────────────────────────────────────────────── + + +class TestOpenAICompatibleProvider: + """Generic OpenAI-compatible provider — Groq, Together AI, Mistral, etc.""" + + def test_returns_none_without_env_vars(self) -> None: + assert OpenAICompatibleProvider().resolve_credentials() is None + + def test_returns_none_with_key_only(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_COMPAT_API_KEY", "gsk_abc") + assert OpenAICompatibleProvider().resolve_credentials() is None + + def test_returns_none_with_url_only(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_COMPAT_BASE_URL", "https://api.groq.com/openai/v1") + assert OpenAICompatibleProvider().resolve_credentials() is None + + def test_resolves_with_both_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_COMPAT_API_KEY", "gsk_abc") + monkeypatch.setenv("SKILLSPECTOR_COMPAT_BASE_URL", "https://api.groq.com/openai/v1") + creds = OpenAICompatibleProvider().resolve_credentials() + assert creds == ("gsk_abc", "https://api.groq.com/openai/v1") + + def test_creates_chat_openai(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_COMPAT_API_KEY", "gsk_abc") + monkeypatch.setenv("SKILLSPECTOR_COMPAT_BASE_URL", "https://api.groq.com/openai/v1") + llm = OpenAICompatibleProvider().create_chat_model( + "llama-3.1-70b-versatile", max_tokens=1024 + ) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "llama-3.1-70b-versatile" + assert str(llm.openai_api_base).rstrip("/") == "https://api.groq.com/openai/v1" + + def test_default_model(self) -> None: + assert OpenAICompatibleProvider().resolve_model() == "llama-3.1-70b-versatile" + + def test_metadata_known_model(self) -> None: + provider = OpenAICompatibleProvider() + assert provider.get_context_length("llama-3.1-70b-versatile") == 131072 + assert provider.get_max_output_tokens("llama-3.1-70b-versatile") == 8192 + + def test_metadata_unknown_model_returns_none(self) -> None: + provider = OpenAICompatibleProvider() + assert provider.get_context_length("some-random-model") is None + + def test_create_returns_none_without_credentials(self) -> None: + assert ( + OpenAICompatibleProvider().create_chat_model( + "llama-3.1-70b-versatile", max_tokens=1024 + ) + is None + ) + + +class TestOpenAICompatibleProviderSelection: + """SKILLSPECTOR_PROVIDER=openai_compatible selects the generic provider.""" + + def test_select_openai_compatible(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "openai_compatible") + assert isinstance(get_metadata_provider(), OpenAICompatibleProvider) + + def test_compat_credentials_via_selector(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "openai_compatible") + monkeypatch.setenv("SKILLSPECTOR_COMPAT_API_KEY", "gsk_abc") + monkeypatch.setenv("SKILLSPECTOR_COMPAT_BASE_URL", "https://api.groq.com/openai/v1") + creds = resolve_provider_credentials() + assert creds == ("gsk_abc", "https://api.groq.com/openai/v1") + + +class TestUnknownProviderError: + """Verify the error message lists all providers including new ones.""" + + def test_error_message_includes_new_providers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "nonexistent") + with pytest.raises(ValueError, match="ollama") as exc_info: + get_metadata_provider() + error_msg = str(exc_info.value) + assert "azure_openai" in error_msg + assert "openai_compatible" in error_msg