Skip to content

Commit 705bc1d

Browse files
committed
feat(providers): add LLM sampling controls
Signed-off-by: Deepak Jain <deepujain@gmail.com>
1 parent d486d0a commit 705bc1d

13 files changed

Lines changed: 184 additions & 10 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ SKILLSPECTOR_REASONING_EFFORT=
2323
# Optional language for human-readable LLM finding text. Machine-readable values
2424
# such as rule IDs and severity values remain unchanged.
2525
SKILLSPECTOR_OUTPUT_LANGUAGE=
26+
# Optional sampling controls. Temperature is supported by hosted providers;
27+
# seed is forwarded only to OpenAI-compatible and Azure OpenAI endpoints.
28+
# Unset or blank values preserve provider defaults.
29+
SKILLSPECTOR_TEMPERATURE= # range: 0..1
30+
SKILLSPECTOR_SEED= # integer
2631

2732
# For SKILLSPECTOR_PROVIDER=anthropic.
2833
ANTHROPIC_API_KEY=

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,8 @@ Issues (2)
585585
| `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | Optional |
586586
| `SKILLSPECTOR_REASONING_EFFORT` | Optional provider- and model-dependent reasoning-effort setting. Non-empty values are trimmed and passed through unchanged; unset or blank preserves provider-default behavior. | Optional |
587587
| `SKILLSPECTOR_OUTPUT_LANGUAGE` | Short, single-line language label (letters, numbers, spaces, `_`, or `-`; maximum 64 characters) for human-readable LLM finding text such as messages, explanations, and remediation. Rule IDs, severity values, paths, code, and other machine-readable values remain unchanged. Unset, blank, or invalid values preserve the default output language. | Optional |
588+
| `SKILLSPECTOR_TEMPERATURE` | Optional sampling temperature from `0` to `1` for hosted providers. Unset or blank preserves the provider default. Lower values can reduce run-to-run variation but do not guarantee identical output. | Optional |
589+
| `SKILLSPECTOR_SEED` | Optional integer sampling seed for OpenAI-compatible and Azure OpenAI providers. Other hosted providers and CLI providers do not receive it. Provider support remains model-dependent. | Optional |
588590
| `ANTHROPIC_API_KEY` | Credential for the Anthropic provider (`SKILLSPECTOR_PROVIDER=anthropic`). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=anthropic` |
589591
| `ANTHROPIC_BASE_URL` | Override the native Anthropic endpoint (default: `https://api.anthropic.com`). | Optional |
590592
| `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` |

docs/DEVELOPMENT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ make install-dev
3838
- **Provider credential**: depends on the active provider — `NVIDIA_INFERENCE_KEY` (NVIDIA), `OPENAI_API_KEY` (OpenAI), or `ANTHROPIC_API_KEY` (Anthropic). See [llm_utils.py](../src/skillspector/llm_utils.py).
3939
- **`OPENAI_BASE_URL`**: Override the OpenAI endpoint (e.g. point at Ollama).
4040
- **`SKILLSPECTOR_MODEL`**: Override default model; see [constants.py](../src/skillspector/constants.py).
41+
- **`SKILLSPECTOR_TEMPERATURE`**: Optional hosted-provider sampling temperature from `0` to `1`.
42+
- **`SKILLSPECTOR_SEED`**: Optional integer seed for OpenAI-compatible and Azure OpenAI providers.
4143

4244
- **Logging**: Internal/operational logging uses the stdlib `logging` module. User-facing output (report body, errors, progress) uses Rich `console.print()`.
4345
- **Env**: `SKILLSPECTOR_LOG_LEVEL` (DEBUG, INFO, WARNING, ERROR). Default is `"WARNING"` (defined in [constants.py](../src/skillspector/constants.py)).
@@ -300,6 +302,8 @@ Copy [.env.example](../.env.example) to `.env` in the project root and set value
300302
| `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | `http://localhost:11434/v1` |
301303
| `SKILLSPECTOR_REASONING_EFFORT` | Optional provider- and model-dependent reasoning-effort setting. Non-empty values are trimmed and passed through unchanged; unset or blank preserves provider-default behavior. | `high` |
302304
| `SKILLSPECTOR_OUTPUT_LANGUAGE` | Optional short, single-line language label (letters, numbers, spaces, `_`, or `-`; maximum 64 characters) for human-readable LLM finding text. Rule IDs, severity values, paths, code, and other machine-readable values remain unchanged. Unset, blank, or invalid values preserve the default output language. | `Japanese` |
305+
| `SKILLSPECTOR_TEMPERATURE` | Optional sampling temperature from `0` to `1` for hosted providers. Unset or blank preserves provider defaults. Lower values reduce variation but do not guarantee identical output. | `0` |
306+
| `SKILLSPECTOR_SEED` | Optional integer sampling seed for OpenAI-compatible and Azure OpenAI providers. Provider/model support is best-effort; CLI providers ignore it. | `42` |
303307
| `ANTHROPIC_API_KEY` | Credential for `SKILLSPECTOR_PROVIDER=anthropic`. | `sk-ant-...` |
304308
| `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` |
305309

src/skillspector/providers/anthropic/provider.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from pydantic import SecretStr
3333

3434
from skillspector.providers import registry
35-
from skillspector.providers.chat_models import resolve_reasoning_effort
35+
from skillspector.providers.chat_models import resolve_reasoning_effort, resolve_sampling_parameters
3636

3737
# Default endpoint; overridden by ``ANTHROPIC_BASE_URL`` when set.
3838
ANTHROPIC_BASE_URL = "https://api.anthropic.com"
@@ -80,6 +80,7 @@ def create_chat_model(
8080
effort = resolve_reasoning_effort()
8181
if effort is not None:
8282
kwargs["effort"] = effort
83+
kwargs.update(resolve_sampling_parameters())
8384
return ChatAnthropic(**kwargs)
8485

8586
def get_context_length(self, model: str) -> int | None:

src/skillspector/providers/anthropic_proxy/provider.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from pydantic import SecretStr
5454

5555
from skillspector.providers import registry
56-
from skillspector.providers.chat_models import resolve_reasoning_effort
56+
from skillspector.providers.chat_models import resolve_reasoning_effort, resolve_sampling_parameters
5757

5858
REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml"))
5959

@@ -244,6 +244,7 @@ def create_chat_model(
244244
effort = resolve_reasoning_effort()
245245
if effort is not None:
246246
kwargs["effort"] = effort
247+
kwargs.update(resolve_sampling_parameters())
247248
return _ChatAnthropicProxy(**kwargs)
248249

249250
def get_context_length(self, model: str) -> int | None:

src/skillspector/providers/azure_openai/provider.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from pydantic import SecretStr
3838

3939
from skillspector.providers import registry
40+
from skillspector.providers.chat_models import resolve_sampling_parameters
4041

4142
REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml"))
4243

@@ -71,14 +72,16 @@ def create_chat_model(
7172
deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT", "").strip() or model
7273
api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "").strip() or "2024-06-01"
7374

74-
return AzureChatOpenAI(
75-
azure_endpoint=endpoint,
76-
azure_deployment=deployment,
77-
api_key=SecretStr(api_key),
78-
api_version=api_version,
79-
max_tokens=max_tokens,
80-
timeout=timeout,
81-
)
75+
kwargs = {
76+
"azure_endpoint": endpoint,
77+
"azure_deployment": deployment,
78+
"api_key": SecretStr(api_key),
79+
"api_version": api_version,
80+
"max_tokens": max_tokens,
81+
"timeout": timeout,
82+
}
83+
kwargs.update(resolve_sampling_parameters(include_seed=True))
84+
return AzureChatOpenAI(**kwargs)
8285

8386
def get_context_length(self, model: str) -> int | None:
8487
return registry.lookup_context_length(REGISTRY_PATH, model)

src/skillspector/providers/bedrock/provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from langchain_core.language_models.chat_models import BaseChatModel
4242

4343
from skillspector.providers import registry
44+
from skillspector.providers.chat_models import resolve_sampling_parameters
4445

4546
BEDROCK_DEFAULT_REGION = "us-west-2"
4647
# Cross-region inference profile ID for Claude Sonnet 4.6. Public,
@@ -129,6 +130,7 @@ def create_chat_model(
129130
}
130131
if model.startswith("arn:"):
131132
kwargs["provider"] = "anthropic"
133+
kwargs.update(resolve_sampling_parameters())
132134

133135
return ChatBedrockConverse(**kwargs)
134136

src/skillspector/providers/chat_models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ def resolve_reasoning_effort() -> str | None:
3434
return reasoning_effort or None
3535

3636

37+
def resolve_sampling_parameters(*, include_seed: bool = False) -> dict[str, float | int]:
38+
"""Resolve optional, validated sampling controls for hosted providers."""
39+
parameters: dict[str, float | int] = {}
40+
raw_temperature = os.environ.get("SKILLSPECTOR_TEMPERATURE", "").strip()
41+
if raw_temperature:
42+
try:
43+
temperature = float(raw_temperature)
44+
except ValueError as exc:
45+
raise ValueError("SKILLSPECTOR_TEMPERATURE must be a number between 0 and 1") from exc
46+
if not 0 <= temperature <= 1:
47+
raise ValueError("SKILLSPECTOR_TEMPERATURE must be between 0 and 1")
48+
parameters["temperature"] = temperature
49+
50+
raw_seed = os.environ.get("SKILLSPECTOR_SEED", "").strip()
51+
if include_seed and raw_seed:
52+
try:
53+
parameters["seed"] = int(raw_seed)
54+
except ValueError as exc:
55+
raise ValueError("SKILLSPECTOR_SEED must be an integer") from exc
56+
return parameters
57+
58+
3759
def validate_base_url(url: str | None) -> None:
3860
"""Warn if *url* is not a well-formed http(s) URL.
3961
@@ -82,4 +104,5 @@ def create_openai_compatible_chat_model(
82104
reasoning_effort = resolve_reasoning_effort()
83105
if reasoning_effort:
84106
kwargs["reasoning_effort"] = reasoning_effort
107+
kwargs.update(resolve_sampling_parameters(include_seed=True))
85108
return ChatOpenAI(**kwargs)

tests/unit/test_anthropic_proxy_provider.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def _clean_env(monkeypatch: pytest.MonkeyPatch):
4343
monkeypatch.delenv("SKILLSPECTOR_PROVIDER", raising=False)
4444
monkeypatch.delenv("SKILLSPECTOR_SSL_VERIFY", raising=False)
4545
monkeypatch.delenv("SKILLSPECTOR_REASONING_EFFORT", raising=False)
46+
monkeypatch.delenv("SKILLSPECTOR_TEMPERATURE", raising=False)
47+
monkeypatch.delenv("SKILLSPECTOR_SEED", raising=False)
4648
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
4749
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
4850
monkeypatch.delenv("NVIDIA_INFERENCE_KEY", raising=False)
@@ -134,6 +136,24 @@ def fake_proxy(**kwargs: object) -> dict[str, object]:
134136

135137
assert "effort" not in captured
136138

139+
def test_temperature_is_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None:
140+
captured: dict[str, object] = {}
141+
142+
def fake_proxy(**kwargs: object) -> dict[str, object]:
143+
captured.update(kwargs)
144+
return kwargs
145+
146+
monkeypatch.setattr(
147+
"skillspector.providers.anthropic_proxy.provider._ChatAnthropicProxy", fake_proxy
148+
)
149+
monkeypatch.setenv("ANTHROPIC_PROXY_API_KEY", "bearer-tok")
150+
monkeypatch.setenv("ANTHROPIC_PROXY_ENDPOINT_URL", "https://proxy.example.com/predict")
151+
monkeypatch.setenv("SKILLSPECTOR_TEMPERATURE", "0.1")
152+
153+
AnthropicProxyProvider().create_chat_model("claude-sonnet-4-6", max_tokens=4096)
154+
155+
assert captured["temperature"] == 0.1
156+
137157

138158
class TestAnthropicProxyProviderMetadata:
139159
"""Token-budget metadata and model resolution tests."""

tests/unit/test_bedrock_provider.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def _clean_provider_env(monkeypatch: pytest.MonkeyPatch):
5151
monkeypatch.delenv("SKILLSPECTOR_MODEL_REGISTRY", raising=False)
5252
monkeypatch.delenv("AWS_PROFILE", raising=False)
5353
monkeypatch.delenv("AWS_REGION", raising=False)
54+
monkeypatch.delenv("SKILLSPECTOR_TEMPERATURE", raising=False)
55+
monkeypatch.delenv("SKILLSPECTOR_SEED", raising=False)
5456
registry._load.cache_clear()
5557
yield
5658
registry._load.cache_clear()
@@ -243,6 +245,28 @@ def test_plain_model_id_does_not_pin_provider(
243245

244246
assert "provider" not in mock_chat.call_args.kwargs
245247

248+
@patch("skillspector.providers.bedrock.provider.ChatBedrockConverse")
249+
@patch("skillspector.providers.bedrock.provider.boto3.Session")
250+
def test_temperature_is_forwarded_without_openai_seed(
251+
self,
252+
mock_session: MagicMock,
253+
mock_chat: MagicMock,
254+
monkeypatch: pytest.MonkeyPatch,
255+
) -> None:
256+
mock_session.return_value.get_credentials.return_value = MagicMock()
257+
mock_session.return_value.client.return_value = MagicMock()
258+
monkeypatch.setenv("SKILLSPECTOR_TEMPERATURE", "0.3")
259+
monkeypatch.setenv("SKILLSPECTOR_SEED", "42")
260+
261+
BedrockProvider().create_chat_model(
262+
"us.anthropic.claude-sonnet-4-6-20250915-v1:0",
263+
max_tokens=1024,
264+
)
265+
266+
kwargs = mock_chat.call_args.kwargs
267+
assert kwargs["temperature"] == 0.3
268+
assert "seed" not in kwargs
269+
246270

247271
class TestBedrockProviderSelection:
248272
"""SKILLSPECTOR_PROVIDER=bedrock activates BedrockProvider."""

0 commit comments

Comments
 (0)