Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test-sdk-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ jobs:
- name: make verify
run: make verify

without-vendor-sdks:
name: Without vendor SDKs
runs-on: ubuntu-latest
defaults:
run:
working-directory: sdks/python
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.13"

# 1.x still lists the vendor SDKs as dependencies, so they are removed again here;
# from 2.0 the plain install brings none and the uninstall step can go.
- name: Install the package without vendor SDKs
run: |
python -m pip install --upgrade pip
pip install . pytest pytest-asyncio
pip uninstall -y openai anthropic google-genai

- name: Every evaluator without them
run: python -m pytest tests/unit/test_without_vendor_sdks.py -p no:cacheprovider

coverage:
name: Coverage
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Check out the <a href="https://docs.learningcommons.org/evaluators/getting-start
| Access method | When to use | How to use |
| :--------------------------------------------------------------------------------------------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Evaluators playground](https://platform.learningcommons.org/apps/evaluators/playground) | For a quick demo of how evaluators work | 1. Open the [Evaluators playground](https://platform.learningcommons.org/apps/evaluators/playground)<br>2. Choose an evaluator family and provide your inputs<br>3. Click **Evaluate**, then expand each result for its score and reasoning |
| [SDK](https://docs.learningcommons.org/evaluators/getting-started/quickstart#sdk) | To integrate into your TypeScript or Python project | 1. Install: `npm install @learning-commons/evaluators` or `pip install learning-commons-evaluators`<br>2. Import an evaluator, pass your API key, and call `evaluate`<br>3. See the [TypeScript](./sdks/typescript/) and [Python](./sdks/python/) SDK READMEs for details |
| [SDK](https://docs.learningcommons.org/evaluators/getting-started/quickstart#sdk) | To integrate into your TypeScript or Python project | 1. Install: `npm install @learning-commons/evaluators` or `pip install "learning-commons-evaluators[all]"`<br>2. Import an evaluator, pass your API key, and call `evaluate`<br>3. See the [TypeScript](./sdks/typescript/) and [Python](./sdks/python/) SDK READMEs for details |
| [Python notebook](https://docs.learningcommons.org/evaluators/getting-started/quickstart#python-notebooks) | For quick prototyping | 1. Start Jupyter Lab: `jupyter lab` (opens at `http://localhost:8888`)<br>2. Browse into `evals/` and open the evaluator you want to try<br>3. Copy your text into the last code cell and run it<br><br>You can also use an IDE with Python and Jupyter support, such as VS Code with Microsoft's Python and Jupyter extensions. |
| Local | To evaluate inputs locally on your machine | Clone this repository and follow the directions for your platform:<br>- [Setup on Mac/Linux](./docs/setup-mac-linux.md)<br>- [Setup on Windows](./docs/setup-windows.md) |

Expand Down
2 changes: 1 addition & 1 deletion demos/python/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ uvicorn
jinja2
python-multipart
python-dotenv
learning-commons-evaluators>=1.0.0
learning-commons-evaluators[all]>=1.0.0
21 changes: 17 additions & 4 deletions sdks/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,29 @@ Python SDK for [Learning Commons evaluators](https://docs.learningcommons.org/ev
## Dependencies

- Python 3.11+
- The provider clients ship with the package (`openai`, `anthropic`, `google-genai`), along with `pydantic`, `httpx`, `pillow`, and `textstat`.
- `pydantic`, `httpx`, `pillow`, and `textstat`, plus the provider clients (`openai`, `anthropic`, `google-genai`), each also available as an extra (see [Installation](#installation)).
- Credentials are constructor arguments. The SDK reads no provider keys from the environment, so a shell `GOOGLE_API_KEY` leaves `google_api_key` unset. A missing key raises `ConfigurationError` at construction, before any I/O.

## Installation

Install the SDK:
Install the SDK with the extra for each provider your evaluators call:

```bash
pip install learning-commons-evaluators
pip install "learning-commons-evaluators[google]" # one provider
pip install "learning-commons-evaluators[openai,google]" # several
pip install "learning-commons-evaluators[all]" # every provider
```

| Extra | Installs | Evaluators that need it by default |
|---|---|---|
| `openai` | `openai` | Revision Accuracy, Revision Actionability, Revision Manageability, Sentence Structure, Strength Acknowledgment, Student Response Specificity, Tone Appropriateness, Vocabulary Complexity, Withholding Answers |
| `google` | `google-genai` | Background Knowledge Demands, Grade Level Appropriateness, Graphics Accuracy, Graphics Complexity, Meaning Directness, Organizational Structure, Purpose Clarity, Reference Knowledge Demands, Vocabulary Complexity |
| `anthropic` | `anthropic` | Critical Thinking, Math Standards Alignment |

A `model_override` needs the extra for its provider instead. An evaluator whose provider's SDK is not installed raises `ConfigurationError` at construction, naming the extra to add.

Version 1.x still installs all three provider clients without an extra, so these lines change nothing today. Version 2.0 will install them only through their extras, and the plain `pip install learning-commons-evaluators` will be for [bringing your own provider](#bring-your-own-provider), which needs none.

Pass the key each evaluator's default provider requires ([which keys](https://docs.learningcommons.org/evaluators/getting-started/quickstart#required-api-keys)):

```python
Expand Down Expand Up @@ -301,10 +313,11 @@ class MyProvider:
evaluator = ToneAppropriatenessEvaluator(llm_provider=MyProvider())
```

- Every LLM step of every evaluator, multi-step ones included, runs on it. No vendor API key is required; one passed anyway is unused. Non-LLM credentials still are: Math Standards Alignment needs `learning_commons_api_key` for the Knowledge Graph.
- Every LLM step of every evaluator, multi-step ones included, runs on it. No vendor SDK or vendor API key is required; one passed anyway is unused. Non-LLM credentials still are: Math Standards Alignment needs `learning_commons_api_key` for the Knowledge Graph.
- It cannot be combined with `model_override`, and an object missing any of the three members raises `ConfigurationError` at construction.
- Evaluators that attach images (the graphics evaluators) also need `supports_attachments = True` on the provider and an `attachments` keyword on `generate_structured`.
- Telemetry and `evaluation.metadata.model` report your `label`. A failed call raises the usual `DependencyError` with `dependency="custom"` and your label as `model`, whatever the label says.
- For failures to be classified and retried like the built-in ones, raise an exception that carries the HTTP status as `status_code` (or a `response` with `status_code` and `headers`), or raise one of the SDK's own errors, such as `RateLimitError`. Anything else becomes a non-retryable `LLMProviderError`.

## More resources

Expand Down
4 changes: 2 additions & 2 deletions sdks/python/docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ From `sdks/python/`:
## Using the SDK before publishing

```bash
pip install -e /path/to/evaluators/sdks/python
pip install -e "/path/to/evaluators/sdks/python[all]"
```

Editable install: changes to SDK source apply without reinstalling.
Editable install: changes to SDK source apply without reinstalling. Name the extras your evaluators need in place of `all`, or none if you inject your own `llm_provider`.

## Regenerating after a change under `evals/`

Expand Down
11 changes: 11 additions & 0 deletions sdks/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,18 @@ dependencies = [
]

[project.optional-dependencies]
# One extra per provider, named by its `Provider` value, so the error for a missing SDK
# names the extra mechanically. Each repeats its pin from `dependencies` above, where the
# SDKs still sit so that no 1.x install changes; 2.0 drops them from there (DSCR-2625),
# leaving the base install for callers who inject their own `llm_provider`. Bump a pin in
# both places until then.
openai = ["openai>=2.47.0,<4"]
anthropic = ["anthropic>=1.0.0,<2"]
google = ["google-genai>=1.39.0,<3"]
all = ["learning-commons-evaluators[openai,anthropic,google]"]
dev = [
# The unit tests build vendor exceptions, and mypy reads the vendor types.
"learning-commons-evaluators[all]",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=5.0.0",
Expand Down
19 changes: 17 additions & 2 deletions sdks/python/src/learning_commons_evaluators/evaluators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
create_provider,
provider_context,
)
from learning_commons_evaluators.providers.factory import missing_sdk_error, sdk_installed
from learning_commons_evaluators.schemas.evaluator import EvaluationResult
from learning_commons_evaluators.schemas.metadata import EvaluatorMetadata
from learning_commons_evaluators.telemetry import (
Expand All @@ -46,8 +47,9 @@ class BaseEvaluator(ABC):
evaluator = PurposeClarityEvaluator(EvaluatorConfig(google_api_key="..."))

:raises ConfigurationError: for a malformed ``model_override``, an ``llm_provider`` that
does not implement :class:`LLMProvider` or is combined with ``model_override``, or a
missing credential the evaluator's contract requires (§3.1), before any I/O.
does not implement :class:`LLMProvider` or is combined with ``model_override``, a
vendor SDK a built-in adapter needs that is not installed, or a missing credential
the evaluator's contract requires (§3.1), before any I/O.
"""

#: The registry contract the evaluator is built from; set by the concrete class.
Expand All @@ -71,6 +73,7 @@ def __init__(self, config: EvaluatorConfig | None = None, /, **fields: Any) -> N

self._validate_llm_provider(config)
self._override_provider = self._validate_model_override(config.model_override)
self._validate_sdks()
self._validate_credentials()

telemetry = config.telemetry_options
Expand Down Expand Up @@ -164,6 +167,18 @@ def providers_in_use(self) -> tuple[Provider, ...]:
return (self._override_provider,)
return self.metadata.default_providers

def _validate_sdks(self) -> None:
"""Every vendor SDK this instance's built-in adapters import is installed.

Checked at construction like a missing key, and for all providers at once, so an
install without the right extra fails before any I/O naming everything to add,
rather than with a bare ``ImportError`` from inside the first evaluation. An
injected ``llm_provider`` uses no built-in adapter, so it needs no vendor SDK.
"""
missing = [provider for provider in self.providers_in_use if not sdk_installed(provider)]
if missing:
raise missing_sdk_error(missing, self.metadata.name)

def _credentials_satisfied_by_injection(self) -> frozenset[str]:
"""Credentials an injected dependency already carries, so the config need not.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,58 @@

from __future__ import annotations

import importlib.util
from collections.abc import Sequence

from learning_commons_evaluators.errors import ConfigurationError
from learning_commons_evaluators.providers.base import LLMProvider, Provider, ProviderConfig

#: The module each built-in adapter imports, and the distribution that provides it. The
#: extra that installs it is named after the provider: ``pip install "...[google]"``.
_SDKS: dict[Provider, tuple[str, str]] = {
Provider.OPENAI: ("openai", "openai"),
Provider.ANTHROPIC: ("anthropic", "anthropic"),
Provider.GOOGLE: ("google.genai", "google-genai"),
}


def sdk_installed(provider: Provider) -> bool:
"""Whether ``provider``'s vendor SDK is importable, found without importing it."""
try:
return importlib.util.find_spec(_SDKS[provider][0]) is not None
except ModuleNotFoundError:
# ``google.genai`` is looked up through ``google``, which may be absent itself.
return False
except ValueError:
# A module placed in ``sys.modules`` without a spec, as a stub in tests may be: it
# is importable, which is what the check is for.
return True


def missing_sdk_error(providers: Sequence[Provider], needed_by: str) -> ConfigurationError:
"""The error for SDKs that are not installed, naming the extra that installs them."""
missing = list(dict.fromkeys(providers))
packages = " and ".join(_SDKS[provider][1] for provider in missing)
extras = ",".join(provider.value for provider in missing)
plural = len(missing) > 1
return ConfigurationError(
f"{needed_by} needs the {packages} package{'s' if plural else ''}, which "
f"{'are' if plural else 'is'} not installed. Install {'them' if plural else 'it'} "
f'with: pip install "learning-commons-evaluators[{extras}]"'
)


def create_provider(config: ProviderConfig) -> LLMProvider:
"""The built-in adapter for ``config.type``.

Adapters import their vendor SDK on construction, so a program that only ever uses one
provider never loads the other two.

:raises ConfigurationError: for an unknown provider, an empty model, or a missing key.
:raises ConfigurationError: for an unknown provider, an empty model, a missing key, or
a vendor SDK that is not installed.
"""
if isinstance(config.type, Provider) and not sdk_installed(config.type):
raise missing_sdk_error([config.type], f"The {config.type.value} provider")
if config.type is Provider.OPENAI:
from learning_commons_evaluators.providers.openai_sdk import OpenAIProvider

Expand All @@ -33,4 +73,4 @@ def create_provider(config: ProviderConfig) -> LLMProvider:
)


__all__ = ["create_provider"]
__all__ = ["create_provider", "missing_sdk_error", "sdk_installed"]
58 changes: 57 additions & 1 deletion sdks/python/tests/unit/evaluators/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
PurposeClarityEvaluator,
TelemetryOptions,
ToneAppropriatenessEvaluator,
VocabularyComplexityEvaluator,
)
from learning_commons_evaluators.evaluators.base import BaseEvaluator
from tests.unit.conftest import ProviderFactory
from learning_commons_evaluators.providers import ProviderConfig
from tests.unit.conftest import FakeProvider, ProviderFactory, sample_for
from tests.unit.evaluators.test_single_step import contract as demo_contract
from tests.unit.evaluators.test_single_step import define as define_demo

Expand Down Expand Up @@ -201,3 +203,57 @@ def test_no_warning_without_an_override(
with caplog.at_level(logging.WARNING, logger="learning_commons_evaluators"):
PurposeClarityEvaluator(google_api_key="k")
assert not [r for r in caplog.records if r.levelno == logging.WARNING]


class TestVendorSDKs:
"""A built-in adapter's vendor SDK is checked at construction, like its key."""

@pytest.fixture
def installed(self, monkeypatch: pytest.MonkeyPatch) -> set[Provider]:
"""The providers whose SDK counts as installed; none until a test adds some."""
present: set[Provider] = set()
monkeypatch.setattr(
"learning_commons_evaluators.evaluators.base.sdk_installed", present.__contains__
)
return present

def test_a_missing_sdk_names_the_evaluator_and_the_extra(
self, providers: ProviderFactory, installed: set[Provider]
) -> None:
with pytest.raises(ConfigurationError) as exc_info:
PurposeClarityEvaluator(google_api_key="k")
assert str(exc_info.value) == (
"Purpose Clarity Evaluator needs the google-genai package, which is not installed. "
'Install it with: pip install "learning-commons-evaluators[google]"'
)

def test_every_missing_sdk_is_named_at_once(
self, providers: ProviderFactory, installed: set[Provider]
) -> None:
with pytest.raises(ConfigurationError, match=r"\[openai,google\]"):
VocabularyComplexityEvaluator(openai_api_key="o", google_api_key="g")
installed.add(Provider.OPENAI)
with pytest.raises(ConfigurationError, match=r"needs the google-genai package.*\[google\]"):
VocabularyComplexityEvaluator(openai_api_key="o", google_api_key="g")

def test_it_is_checked_before_the_keys(
self, providers: ProviderFactory, installed: set[Provider]
) -> None:
# Installing comes before configuring, so the first error is the one to fix first.
with pytest.raises(ConfigurationError, match="google-genai"):
PurposeClarityEvaluator()

def test_an_override_needs_its_own_providers_sdk_instead(
self, providers: ProviderFactory, installed: set[Provider]
) -> None:
override = ModelOverride(provider=Provider.ANTHROPIC, model="claude-haiku-4-5-20251001")
with pytest.raises(ConfigurationError, match=r"\[anthropic\]"):
PurposeClarityEvaluator(anthropic_api_key="a", model_override=override)
installed.add(Provider.ANTHROPIC)
PurposeClarityEvaluator(anthropic_api_key="a", model_override=override)

def test_an_injected_provider_needs_none(
self, providers: ProviderFactory, installed: set[Provider]
) -> None:
injected = FakeProvider(ProviderConfig(type=Provider.OPENAI, model="m"), sample_for)
VocabularyComplexityEvaluator(llm_provider=injected)
Loading
Loading