Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ NVIDIA_INFERENCE_KEY=
# etc.); leave unset for stock api.openai.com.
OPENAI_API_KEY=
OPENAI_BASE_URL=
# Optional for OpenAI-compatible providers; unset or blank uses the provider default.
SKILLSPECTOR_REASONING_EFFORT=

# For SKILLSPECTOR_PROVIDER=anthropic.
ANTHROPIC_API_KEY=
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ Issues (2)
| `NVIDIA_INFERENCE_KEY` | Credential for the `nv_build` provider (build.nvidia.com). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=nv_build` |
| `OPENAI_API_KEY` | Credential for the OpenAI provider (`SKILLSPECTOR_PROVIDER=openai`). Also serves as the tier-2 fallback in the credential waterfall when the active provider returns no credentials. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=openai` |
| `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | Optional |
| `SKILLSPECTOR_REASONING_EFFORT` | Optional reasoning-effort literal for OpenAI-compatible providers; provider/model dependent. Unset or blank preserves provider-default behavior. | Optional |
| `ANTHROPIC_API_KEY` | Credential for the Anthropic provider (`SKILLSPECTOR_PROVIDER=anthropic`). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=anthropic` |
| `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` |
| `ANTHROPIC_PROXY_API_KEY` | Bearer token for the Anthropic proxy provider. | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` |
Expand Down
1 change: 1 addition & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ Copy [.env.example](../.env.example) to `.env` in the project root and set value
| `NVIDIA_INFERENCE_KEY` | Credential for `nv_build`. | `nvapi-...` |
| `OPENAI_API_KEY` | Credential for `SKILLSPECTOR_PROVIDER=openai`. Also tier-2 fallback for non-OpenAI providers. | `sk-...` |
| `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | `http://localhost:11434/v1` |
| `SKILLSPECTOR_REASONING_EFFORT` | Optional reasoning-effort literal for OpenAI-compatible providers; provider/model dependent. Unset or blank preserves provider-default behavior. | `high` |
| `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). For `claude_cli`, this is passed as `--model` to the `claude` binary. | `gpt-5.2` |

Expand Down
21 changes: 13 additions & 8 deletions src/skillspector/providers/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

import logging
import os
from urllib.parse import urlparse

from langchain_core.language_models.chat_models import BaseChatModel
Expand Down Expand Up @@ -64,11 +65,15 @@ def create_openai_compatible_chat_model(

api_key, base_url = credentials
validate_base_url(base_url)
return ChatOpenAI(
model=model,
base_url=base_url,
api_key=SecretStr(api_key),
max_completion_tokens=max_tokens,
timeout=timeout,
default_headers=default_headers,
)
kwargs = {
"model": model,
"base_url": base_url,
"api_key": SecretStr(api_key),
"max_completion_tokens": max_tokens,
"timeout": timeout,
"default_headers": default_headers,
}
reasoning_effort = os.environ.get("SKILLSPECTOR_REASONING_EFFORT", "").strip()
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
return ChatOpenAI(**kwargs)
1 change: 1 addition & 0 deletions tests/unit/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _clean_env(monkeypatch: pytest.MonkeyPatch):
"NVIDIA_INFERENCE_KEY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"SKILLSPECTOR_REASONING_EFFORT",
"ANTHROPIC_API_KEY",
):
monkeypatch.delenv(key, raising=False)
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
import pytest
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
from pydantic import SecretStr

import skillspector.providers as providers_module
from skillspector.providers import (
NO_LLM_API_KEY_MESSAGE,
chat_models,
create_chat_model,
get_active_provider,
get_metadata_provider,
Expand Down Expand Up @@ -113,6 +115,7 @@ def _clean_provider_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_PROJECT_ID", raising=False)
monkeypatch.delenv("SKILLSPECTOR_REASONING_EFFORT", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("SKILLSPECTOR_MODEL", raising=False)
monkeypatch.delenv("SKILLSPECTOR_MODEL_REGISTRY", raising=False)
Expand Down Expand Up @@ -344,6 +347,117 @@ def test_builds_chat_openai_from_credentials(self) -> None:
assert llm.max_tokens == 123
assert str(llm.openai_api_base).rstrip("/") == "http://localhost:1234/v1"

def test_reasoning_effort_configured(self, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}

def fake_chat_openai(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return kwargs

monkeypatch.setattr(chat_models, "ChatOpenAI", fake_chat_openai)
monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", " high ")

create_openai_compatible_chat_model(
model="gpt-5.4",
credentials=("sk-x", "http://localhost:1234/v1"),
max_tokens=123,
)

assert captured["reasoning_effort"] == "high"

def test_reasoning_effort_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}

def fake_chat_openai(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return kwargs

monkeypatch.setattr(chat_models, "ChatOpenAI", fake_chat_openai)

create_openai_compatible_chat_model(
model="gpt-5.4",
credentials=("sk-x", "http://localhost:1234/v1"),
max_tokens=123,
)

assert "reasoning_effort" not in captured
assert captured["max_completion_tokens"] == 123

@pytest.mark.parametrize("blank_value", [" ", "\t\n"])
def test_reasoning_effort_blank(
self, monkeypatch: pytest.MonkeyPatch, blank_value: str
) -> None:
captured: dict[str, object] = {}

def fake_chat_openai(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return kwargs

monkeypatch.setattr(chat_models, "ChatOpenAI", fake_chat_openai)
monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", blank_value)

create_openai_compatible_chat_model(
model="gpt-5.4",
credentials=("sk-x", "http://localhost:1234/v1"),
max_tokens=123,
)

assert "reasoning_effort" not in captured
assert captured["max_completion_tokens"] == 123

def test_reasoning_effort_provider_matrix(self, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}

def fake_chat_openai(**kwargs: object) -> dict[str, object]:
captured.clear()
captured.update(kwargs)
return kwargs

monkeypatch.setattr(chat_models, "ChatOpenAI", fake_chat_openai)
cases = (
(OpenAIProvider(), "OPENAI_API_KEY", "sk-x", "http://localhost:1234/v1"),
(NvBuildProvider(), "NVIDIA_INFERENCE_KEY", "nvapi-x", BUILD_BASE_URL),
)
for provider, key, value, endpoint in cases:
monkeypatch.setenv(key, value)
if isinstance(provider, OpenAIProvider):
monkeypatch.setenv("OPENAI_BASE_URL", endpoint)
monkeypatch.setenv("OPENAI_PROJECT_ID", "proj_123")
for effort in (None, " ", " high "):
if effort is None:
monkeypatch.delenv("SKILLSPECTOR_REASONING_EFFORT", raising=False)
else:
monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", effort)
provider.create_chat_model("model-x", max_tokens=123)
assert captured["base_url"] == endpoint
assert captured["max_completion_tokens"] == 123
assert isinstance(captured["api_key"], SecretStr)
assert captured["api_key"].get_secret_value() == value
if isinstance(provider, OpenAIProvider):
assert captured["default_headers"] == {"OpenAI-Project": "proj_123"}
if effort is None or not effort.strip():
assert "reasoning_effort" not in captured
else:
assert captured["reasoning_effort"] == "high"

def test_reasoning_effort_passthrough(self, monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}

def fake_chat_openai(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return kwargs

monkeypatch.setattr(chat_models, "ChatOpenAI", fake_chat_openai)
monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", "provider-specific-value")

create_openai_compatible_chat_model(
model="gpt-5.4",
credentials=("sk-x", "http://localhost:1234/v1"),
max_tokens=123,
)

assert captured["reasoning_effort"] == "provider-specific-value"


class TestProviderSelection:
"""SKILLSPECTOR_PROVIDER selects which provider answers credentials."""
Expand Down
Loading