Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to scolta-python are documented here.

## [Unreleased]

### Changed
- **Scolta ships with no AI provider selected, and `AutoProvisioner.ensure_ai_available()` no longer mints a trial (`src/scolta/config.py`, `src/scolta/health.py`, `src/scolta/ai/client.py`, `src/scolta/ai/service.py`, `src/scolta/ai/amazee/auto_provisioner.py`).** Two invariants, previously true in the PHP core and only half-true here. **No default provider:** `ScoltaConfig.ai_provider` defaulted to `"anthropic"`, `HealthChecker` coalesced an empty value back to `"anthropic"`, and `AiClient` defaulted its `provider` key the same way — so an install nobody had configured reported itself as an Anthropic install, and a key set in the environment before anyone chose a provider looked like a working one. The default is now `""`, nothing coalesces it, and `""` means AI is off: `AiServiceAdapter._get_client()` refuses to build a client and raises the `ApiKeyMissingException` the callers already degrade on (unexpanded query, no summary) rather than picking a vendor on the site's behalf; `AiClient` rejects an absent provider outright; health reports `ai_provider: ""` and a new `ai_provider_selected: False`, and `ai_usable` is false whatever else is present. Going-forward only — a provider already persisted by a site is read as-is and never rewritten. **No auto-enable:** `ensure_ai_available()` still constructed an `AmazeeTrialProvisioner` and minted a free trial whenever it found an empty credential store, so any request, cron or install path that called it enrolled a site that had never opted in. That branch is deleted. The method now matches the PHP core exactly: it self-heals credentials that are already stored but whose model names were never resolved, and does nothing else. A connection is established only by an explicit `AmazeeTrialProvisioner.provision()` call from an operator action. Covered by `tests/ai/amazee/test_manual_provider_and_opt_in.py`, whose Amazee transports fail the test if they are called at all.
- **Which action established an Amazee.ai connection is recorded when it happens (`src/scolta/ai/amazee/connection_source.py`, `storage.py`, `trial_provisioner.py`, `account_upgrader.py`).** Nothing recorded whether a stored token came from the demo or from an operator's own account — both write the same three fields through `ConfigStorage.store()` — so any surface naming one was guessing. `AmazeeConnectionSource` (`demo` / `account`) is now written by the class that establishes the connection, through the new `ProvenanceAwareConfigStorage` sub-interface. Kept as a sub-interface so every existing `ConfigStorage` implementation keeps working untouched and simply reports no provenance, which is the honest answer for credentials connected before this release.

### Removed
- **`AutoProvisioner.ensure_ai_available()` no longer provisions anything, and its return value is always `False`.** It previously returned `True` when it had minted a fresh trial. There is no longer a success to report. The parameter list is unchanged, so callers keep compiling; a caller that branched on `True` should move that branch to its own explicit `provision()` call. Two tests that asserted the mint behaviour are replaced by tests asserting its absence.

### Fixed
- **Re-vendored the browser bundle (`src/scolta/assets/js/scolta.js`) from scolta-php: the AI summary's "Show more" control now follows the viewport width, and a summarize failure can no longer strand the loading skeleton**
([tag1consulting/scolta-php#269](https://github.com/tag1consulting/scolta-php/pull/269)).
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ binding.
Platform integration for Django/Wagtail lives in the companion `scolta-django`
package.

## Selecting an AI provider is always manual

Scolta ships with **no AI provider selected**. `ai_provider` is empty until
somebody sets it, and while it is empty AI features are simply off: search
works, no provider is assumed, and Anthropic in particular is not silently
assumed. There is no default anywhere.

A developer sets `ai_provider` in code or settings; in `scolta-django` an
operator picks one in the admin. Both are explicit acts. This is a
going-forward rule: a site that already persisted a provider keeps it, and
nothing rewrites an existing value.

**Amazee.ai is never enabled on its own.** No credential is provisioned and no
outbound Amazee call is made on a request, cron, install or activation path for
a site that has not opted in. `AutoProvisioner.ensure_ai_available()` — whose
name predates the policy — establishes nothing: it only re-resolves gateway
model names against a key already on disk, which is reachable only for a site
that already connected. A connection is established solely by an explicit call
to `AmazeeTrialProvisioner.provision()` (the free demo, no email required) or
`AmazeeAccountUpgrader` (the email → verification code → region flow that
attaches an amazee.ai account). Amazee support is email-only, mirroring
amazee.ai's own `ai_provider_amazeeio` module; there is no paste-your-API-key
path.

Which of those two established a connection is **recorded** at the time it
happens, through `ProvenanceAwareConfigStorage`, so a surface can report a demo
or an account from a stored fact instead of a guess. Credentials with no
recorded origin claim nothing.

## Status

Complete port of `scolta-php`, released as 1.0.x. See `CLAUDE.md` for the
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ out of sync with the code.

| Property | Type | Default | Description |
|---|---|---|---|
| `ai_provider` | string | `anthropic` | |
| `ai_provider` | string | `(empty)` | AI provider identifier (`anthropic`, `openai`). No default: empty means no provider has been selected and AI features are off. Selecting one is always explicit. |
| `ai_api_key` | string | `(empty)` | |
| `ai_model` | string | `claude-sonnet-4-5-20250929` | |
| `ai_expansion_model` | string | `(empty)` | |
Expand Down
16 changes: 11 additions & 5 deletions src/scolta/ai/amazee/__init__.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
"""Amazee.ai auto-provisioning subsystem (port of ``AiProvider\\Amazee``).
"""Amazee.ai managed-gateway subsystem (port of ``AiProvider\\Amazee``).

A managed LiteLLM gateway: provision a free trial (anonymous or by email),
resolve the best Claude models, and upgrade to a private key via an email-OTP
flow. The returned credentials configure the OpenAI-compatible AiClient path.
A managed LiteLLM gateway, connected only when an operator opts in. Two explicit
paths establish a connection: the free demo (anonymous, no email) and the
email-OTP account flow, which is also how an operator continues once the demo
credit runs out. Nothing here connects a site on its own — ``AutoProvisioner``
only re-resolves model names against credentials that are already stored. The
returned credentials configure the OpenAI-compatible AiClient path.
"""

from .account_upgrader import AmazeeAccountUpgrader
from .auto_provisioner import AutoProvisioner
from .budget_decorator import BudgetAwareProviderDecorator
from .client import AmazeeClient
from .connection_source import AmazeeConnectionSource
from .exceptions import AmazeeApiException, AmazeeBudgetExceededException
from .key_expiry_recovery import KeyExpiryRecovery
from .model_resolver import AmazeeModelResolver
from .results import ProvisioningResult, UpgradeResult
from .storage import ConfigStorage
from .storage import ConfigStorage, ProvenanceAwareConfigStorage
from .trial_provisioner import AmazeeTrialProvisioner

__all__ = [
"AmazeeAccountUpgrader",
"AmazeeApiException",
"AmazeeBudgetExceededException",
"AmazeeClient",
"AmazeeConnectionSource",
"AmazeeModelResolver",
"AmazeeTrialProvisioner",
"AutoProvisioner",
"BudgetAwareProviderDecorator",
"ConfigStorage",
"KeyExpiryRecovery",
"ProvenanceAwareConfigStorage",
"ProvisioningResult",
"UpgradeResult",
]
26 changes: 25 additions & 1 deletion src/scolta/ai/amazee/account_upgrader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,27 @@
from __future__ import annotations

from .client import AmazeeClient
from .connection_source import AmazeeConnectionSource
from .results import UpgradeResult
from .storage import ConfigStorage
from .storage import ConfigStorage, ProvenanceAwareConfigStorage


class AmazeeAccountUpgrader:
"""Connects a site to an amazee.ai account, by email.

The only way to reach a real amazee.ai account, and email-only by design: it
mirrors amazee.ai's own ``ai_provider_amazeeio`` Drupal module, where an
operator never generates or pastes an API key. Signing in returns the
account's credentials and Scolta persists them. There is deliberately no
bring-your-own-key path — an operator who already holds an account attaches
it by signing in with that account's email, and the same flow creates the
account when it does not exist yet.

It serves two operator journeys with the same steps: connecting an account
from a clean install, and continuing after the demo credit runs out, which
:class:`KeyExpiryRecovery` flags with its upgrade-needed marker.
"""

def __init__(self, client: AmazeeClient, storage: ConfigStorage) -> None:
self.client = client
self.storage = storage
Expand All @@ -22,6 +38,14 @@ def list_regions(self, session_token: str) -> list:
return self.client.list_regions(session_token)

def upgrade(self, session_token: str, region_id: str) -> UpgradeResult:
"""Provision a private AI key in the given region and store it.

New credentials replace any existing stored credentials — including a
demo connection this account is replacing — and the connection source is
recorded as ``ACCOUNT`` when the store supports it.
"""
result = self.client.create_private_key(session_token, region_id)
self.storage.store(result.litellm_token, result.litellm_api_url, result.region)
if isinstance(self.storage, ProvenanceAwareConfigStorage):
self.storage.store_connection_source(AmazeeConnectionSource.ACCOUNT)
return result
124 changes: 69 additions & 55 deletions src/scolta/ai/amazee/auto_provisioner.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
"""Idempotent auto-provisioning guard (port of AutoProvisioner)."""
"""Self-heal guard for stored managed-gateway credentials (port of AutoProvisioner)."""

from __future__ import annotations

from collections.abc import Callable

from .client import AmazeeClient
from .exceptions import AmazeeApiException
from .model_resolver import AmazeeModelResolver
from .results import ProvisioningResult
from .storage import ConfigStorage
from .trial_provisioner import AmazeeTrialProvisioner


class AutoProvisioner:
"""Keeps already-stored managed-gateway credentials usable.

This helper never establishes a managed gateway connection. Establishing one
is an explicit caller action: an operator-initiated enable path calls
:meth:`AmazeeTrialProvisioner.provision` directly. Nothing here does it on
the caller's behalf, from an install hook, from a request path, or behind a
flag.

The name predates the policy and is kept for callers compiled against it.
What remains is :meth:`ensure_ai_available`: a self-heal for credentials that
are already stored but whose model names were never resolved.
"""

@staticmethod
def ensure_ai_available(
storage: ConfigStorage,
Expand All @@ -21,65 +31,69 @@ def ensure_ai_available(
client: AmazeeClient | None = None,
has_resolved_models: Callable[[], bool] | None = None,
) -> bool:
"""Provision a free trial unless AI is already configured. Idempotent;
no-op when an explicit key exists or credentials are already stored.
Returns True only when a fresh trial was provisioned.

The stored-credentials no-op deliberately does NOT validate that the
stored key still works — trial keys are revoked server-side when the
trial ends, and that expiry is not announced at provisioning time, so a
cheap install-hook/lazy-init guard cannot know. Call-time auth failures
are the reliable signal: :class:`KeyExpiryRecovery` detects them, records
the failure for health, and flags the site for admin re-authentication
without requesting replacement credentials.

Stored credentials are treated as a *complete* provision only once their
model names are resolved. A provision whose ``/model/info`` call failed
stores the token+url with no models, leaving the caller to fall back to
the dated config default — which the Amazee gateway rejects with HTTP
400, breaking AI permanently because this guard kept no-opping on the
half-provisioned credentials. When the caller can confirm models are
still unresolved (via ``has_resolved_models``), model resolution is
re-attempted against the ALREADY-STORED key — never a fresh trial, which
would waste a server-side-limited allocation — so the incomplete-provision
state self-heals. Without that callback the historical no-op stands: the
caller cannot tell us, and we must not re-resolve blindly every request.
"""Re-resolve model names for credentials that are already stored.

This method never establishes a managed gateway connection, and it makes
no outbound call at all unless credentials are already stored. It is a
no-op when:

- ``has_explicit_api_key`` is true (the caller has their own provider),
- no credentials are stored — nothing to heal, and nothing is
established here; that is :meth:`AmazeeTrialProvisioner.provision`,
reached only from an explicit operator action, or
- credentials are stored and ``has_resolved_models`` is absent or
reports that model names are already resolved.

The stored-credentials path deliberately does NOT validate that the
stored key still works — credentials are revoked server-side when their
lifecycle ends, and that is not announced at issue time, so a cheap
lazy-init guard cannot know. Call-time auth failures are the reliable
signal: :class:`KeyExpiryRecovery` detects them, records the failure for
health, and flags the site for admin re-authentication without
requesting replacement credentials.

Stored credentials are, however, usable only once their model names have
been resolved. Credentials stored while ``/model/info`` was unreachable
carry no resolved models, leaving the caller to fall back to the dated
config default — which the Amazee gateway rejects with HTTP 400, breaking
AI permanently because this guard kept no-opping on the half-configured
credentials. When the caller can confirm models are still unresolved (via
``has_resolved_models``), model resolution is re-attempted against the
ALREADY-STORED key, so that state self-heals. Without that callback the
historical no-op stands: the caller cannot tell us, and we must not
re-resolve blindly on every request.

Returns:
Always ``False``. The return value is retained for callers written
against the previous signature; nothing is established here, so
there is no success to report.
"""
if has_explicit_api_key:
return False

credentials = storage.load()
if credentials is not None:
# Already provisioned. Self-heal only an incomplete provision — one
# whose model resolution failed, leaving credentials with no models
# — and only when the caller can confirm that state. Re-resolve
# against the stored key (not a new trial) and persist the result.
if has_resolved_models is None or has_resolved_models():
return False

models = AmazeeModelResolver(client or AmazeeClient()).resolve(
credentials["litellm_api_url"], credentials["litellm_token"]
)
if on_models_resolved is not None and (
models["ai_model"] is not None or models["ai_expansion_model"] is not None
):
on_models_resolved(models["ai_model"] or "", models["ai_expansion_model"] or "")
return False

amazee_client = client or AmazeeClient()
provisioner = AmazeeTrialProvisioner(
amazee_client, storage, None, AmazeeModelResolver(amazee_client)
)
try:
result = provisioner.provision()
except AmazeeApiException:
if credentials is None:
# POLICY: nothing is established here. Automatic enrollment was
# removed outright — there is no automatic path and no flag-gated
# one. A managed gateway connection is established only by an
# explicit operator action that calls
# AmazeeTrialProvisioner.provision(). With no stored credentials
# this is a no-op that makes no outbound call.
return False

if not result.success or result.status != ProvisioningResult.STATUS_PROVISIONED:
# Credentials are stored. Self-heal only the incomplete case — model
# resolution never completed, leaving credentials with no models — and
# only when the caller can confirm that state. Re-resolve against the
# stored key and persist the result.
if has_resolved_models is None or has_resolved_models():
return False

models = AmazeeModelResolver(client or AmazeeClient()).resolve(
credentials["litellm_api_url"], credentials["litellm_token"]
)
if on_models_resolved is not None and (
result.ai_model is not None or result.ai_expansion_model is not None
models["ai_model"] is not None or models["ai_expansion_model"] is not None
):
on_models_resolved(result.ai_model or "", result.ai_expansion_model or "")
return True
on_models_resolved(models["ai_model"] or "", models["ai_expansion_model"] or "")

return False
Loading